diff --git a/.github/workflows/optional-contracts.yml b/.github/workflows/optional-contracts.yml new file mode 100644 index 000000000..2a2809b6a --- /dev/null +++ b/.github/workflows/optional-contracts.yml @@ -0,0 +1,203 @@ +name: Dependency Contracts + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths: + - 'src/langbot/**' + - 'tests/**' + - 'pyproject.toml' + - 'pytest.ini' + - 'uv.lock' + - '.github/workflows/optional-contracts.yml' + push: + branches: [master, develop] + paths: + - 'src/langbot/**' + - 'tests/**' + - 'pyproject.toml' + - 'pytest.ini' + - 'uv.lock' + - '.github/workflows/optional-contracts.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + compiled-cli: + name: Compiled CLI HTTP Contract + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + env: + GOTOOLCHAIN: local + CGO_ENABLED: '0' + + steps: + - name: Checkout Core + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Checkout official CLI source + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-cli + # lbctl v0.2.0; advance only after verifying the Core HTTP contract. + ref: 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9 + path: .contract-sources/langbot-cli + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + # Match the pinned CLI's release toolchain. + go-version: '1.26.8' + cache-dependency-path: .contract-sources/langbot-cli/go.sum + + - name: Build pinned CLI + working-directory: .contract-sources/langbot-cli + env: + LANGBOT_CLI_BIN: ${{ runner.temp }}/langbot-cli/lbctl + run: | + test "$(git rev-parse HEAD)" = 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9 + go mod download + go mod verify + mkdir -p "$(dirname "$LANGBOT_CLI_BIN")" + go build -mod=readonly -trimpath -o "$LANGBOT_CLI_BIN" ./cmd/lbctl + test -x "$LANGBOT_CLI_BIN" + go version -m "$LANGBOT_CLI_BIN" + + - 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 Core dependencies + run: uv sync --dev --frozen + + - name: Run required compiled CLI contract + env: + LANGBOT_CLI_BIN: ${{ runner.temp }}/langbot-cli/lbctl + run: | + uv run --no-sync pytest --required-suite compiled_cli \ + -q --tb=short -rs --junitxml=compiled-cli-results.xml + + - name: Upload compiled CLI contract result + if: always() + uses: actions/upload-artifact@v4 + with: + name: compiled-cli-contract-result + path: compiled-cli-results.xml + if-no-files-found: ignore + + plugin-identity: + name: Official Plugin Identity Contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout Core + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Read official source pin + id: plugin-source + run: | + python3 - <<'PY' + import json + import os + import re + from pathlib import Path + + lock = json.loads(Path('tests/fixtures/legacy_plugin_identity_sources.json').read_text()) + assert lock['repository'] == 'langbot-app/langbot-plugins' + assert re.fullmatch(r'[0-9a-f]{40}', lock['revision']) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f"revision={lock['revision']}\n") + PY + + - name: Checkout pinned official plugin sources + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-plugins + ref: ${{ steps.plugin-source.outputs.revision }} + path: .contract-sources/langbot-plugins + persist-credentials: false + + - 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 Core dependencies + run: uv sync --dev --frozen + + - name: Run required identity consumer contracts + env: + LANGBOT_LEGACY_PLUGIN_ROOT: ${{ github.workspace }}/.contract-sources/langbot-plugins + run: | + uv run --no-sync pytest --required-suite plugin_identity \ + -q --tb=short -rs --junitxml=plugin-identity-results.xml + + - name: Upload plugin identity results + if: always() + uses: actions/upload-artifact@v4 + with: + name: plugin-identity-contract-results + path: plugin-identity-results.xml + if-no-files-found: ignore + + seekdb: + name: Embedded SeekDB Contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout Core + uses: actions/checkout@v4 + with: + persist-credentials: false + + - 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 locked SeekDB extra + run: uv sync --dev --extra seekdb --frozen + + - name: Run required embedded SeekDB contracts + env: + SEEKDB_TEST_TMP: ${{ runner.temp }}/langbot-seekdb-contract + run: | + uv run --no-sync python - <<'PY' + from importlib.metadata import version + import pyseekdb + import pylibseekdb + + print(f"pyseekdb={version('pyseekdb')}, pylibseekdb={version('pylibseekdb')}") + PY + uv run --no-sync pytest --required-suite seekdb \ + -q --tb=short -rs --durations=5 \ + --basetemp="$SEEKDB_TEST_TMP" --junitxml=seekdb-contract-results.xml + + - name: Upload SeekDB results and startup logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: seekdb-contract-results + path: | + seekdb-contract-results.xml + ${{ runner.temp }}/langbot-seekdb-contract/**/log/*.log + if-no-files-found: ignore diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index aaee59549..bd54e498f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,6 +11,7 @@ on: - 'uv.lock' - 'run_tests.sh' - 'scripts/test-*.sh' + - 'pytest.ini' push: branches: - master @@ -47,6 +48,19 @@ jobs: - name: Run unit + smoke tests run: uv run pytest tests/unit_tests/ tests/smoke/ -q --tb=short + - name: Run required security regressions + run: | + uv run pytest --required-suite security \ + -q --tb=short -rs --junitxml=cwe94-results.xml + + - name: Upload security regression results + if: always() + uses: actions/upload-artifact@v4 + with: + name: cwe94-results-${{ matrix.python-version }} + path: cwe94-results.xml + if-no-files-found: ignore + - name: Test Summary if: always() run: | @@ -84,9 +98,30 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Test Status: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY - e2e: - name: E2E Startup Tests + postgres-contracts: + name: PostgreSQL Contract Tests runs-on: ubuntu-latest + timeout-minutes: 15 + # Only this disposable, loopback-bound CI service uses trust authentication. + # Admission fixtures create a separate unprivileged role with no password; + # the literal postgres@ URL lets that fixture switch to the runtime role. + env: + TEST_POSTGRES_URL: postgresql+asyncpg://postgres@localhost:5432/langbot_test + LANGBOT_ADMISSION_TEST_URL: postgresql+asyncpg://postgres@localhost:5432/langbot_admission_test + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_DB: langbot_test + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - '127.0.0.1:5432:5432' + options: >- + --health-cmd="pg_isready -U postgres -d langbot_test" + --health-interval=5s + --health-timeout=5s + --health-retries=5 steps: - name: Checkout code @@ -100,11 +135,91 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --dev --frozen + + - name: Create isolated admission test database + run: >- + docker exec "${{ job.services.postgres.id }}" + psql -U postgres -d postgres -v ON_ERROR_STOP=1 + -c 'CREATE DATABASE langbot_admission_test;' + + - name: Run required PostgreSQL contracts + run: | + uv run pytest --required-suite postgres \ + -q --tb=short -rs --durations=10 --junitxml=postgres-contract-results.xml + + - name: Upload PostgreSQL contract results + if: always() + uses: actions/upload-artifact@v4 + with: + name: postgres-contract-results + path: postgres-contract-results.xml + if-no-files-found: ignore + + e2e: + name: E2E Startup Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read official plugin source pin + id: plugin-source + run: | + python3 - <<'PY' + import json + import os + import re + from pathlib import Path + + lock = json.loads(Path('tests/fixtures/legacy_plugin_identity_sources.json').read_text()) + assert lock['repository'] == 'langbot-app/langbot-plugins' + assert re.fullmatch(r'[0-9a-f]{40}', lock['revision']) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f"revision={lock['revision']}\n") + PY + + - name: Checkout pinned official plugin sources + uses: actions/checkout@v4 + with: + repository: langbot-app/langbot-plugins + ref: ${{ steps.plugin-source.outputs.revision }} + path: .contract-sources/langbot-plugins + persist-credentials: false + + - 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 + run: uv run pytest tests/e2e -m "not local_agent" -q --tb=short + + - name: Check Docker for the required LocalAgent Box contract + run: docker info + + - name: Run required LocalAgent contracts + env: + LANGBOT_LEGACY_PLUGIN_ROOT: ${{ github.workspace }}/.contract-sources/langbot-plugins + run: | + uv run --no-sync pytest --required-suite local_agent \ + -q --tb=short -rs --junitxml=local-agent-results.xml + + - name: Upload LocalAgent contract results + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-agent-contract-results + path: local-agent-results.xml + if-no-files-found: ignore - name: E2E Test Summary if: always() @@ -148,9 +263,16 @@ jobs: coverage: name: Coverage Gate runs-on: ubuntu-latest - needs: [test, integration] + needs: [test, integration, postgres-contracts] + if: always() steps: + - name: Verify required test jobs + run: | + test "${{ needs.test.result }}" = success + test "${{ needs.integration.result }}" = success + test "${{ needs.postgres-contracts.result }}" = success + - name: Checkout code uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 4fb546042..96f8c6903 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,11 +87,23 @@ Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml - When changing Service API routes or capabilities used by `lbctl`, check compatibility with the separate `langbot-cli` repository. - New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`. LangBot 4.x does not support upgrading 3.x databases. - 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). +- User-facing strings must support i18n; follow the backend language rules below for server-generated content. +- Conversation payload columns, including new ones, must use `MediaReferenceText` and persist image references only. See the [media persistence contract](ARCHITECTURE.md#persistence-and-configuration). - 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: `(): `. +## Backend Message Language + +- LangBot-owned business errors, runtime notices, and default messages sent to users must use the owning Workspace's message language. Keep this independent of each member's WebUI language; an unset Workspace language defaults to `en_US`. +- Reuse `src/langbot/pkg/i18n/` and the existing `message_language` entry in `WorkspaceMetadata`. Use `t()` with stable English templates and named parameters; add matching keys and placeholders to all eight catalogs: `en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, `es_ES`, `ru_RU`, `th_TH`, and `vi_VN`. Avoid new translation frameworks or duplicate language settings. +- Resolve language from a trusted Workspace or bot execution context, and isolate it across concurrent requests, tasks, and adapter callbacks. Browser language hints may supply account-only or unauthenticated response language, but must not override a resolved Workspace or authorize tenant selection. Reading/changing the setting requires `workspace.view` / `workspace.update`. +- Pipeline notices should be configurable in Pipeline output settings through `pipeline_notice()`. Blank overrides inherit the Workspace translation. Preserve user-authored text literally except for documented placeholders, and retain existing visibility switches. Do not rewrite historical messages or custom text when the language changes. +- China-only adapters and configuration/field validation diagnostics do not require localization. An adapter also serving international users is not exempt merely because it supports a Chinese platform. +- Preserve raw provider/plugin diagnostics, tracebacks, protocol values, and machine-readable error/status codes. Translate LangBot's own category or explanation around them; do not translate arbitrary errors with substring replacement. Plugin-authored messages remain the plugin's localization responsibility. +- Keep changes small and maintainable. Reuse existing persistence and response boundaries; delete obsolete fields only after verifying they have no remaining readers, including compatibility paths. +- When changing language behavior, verify catalog/placeholder parity, Workspace isolation and permissions, and Pipeline inheritance/custom overrides. Update affected configuration snapshots and API/MCP documentation. Implementation details and focused test locations: [Backend message language](docs/architecture/backend-i18n.md). + ## Runtime Pitfalls - Local stdio Plugin Runtime disconnects do not auto-reconnect; restart LangBot if that path breaks. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6eaa9a72..f99f926a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -190,14 +190,17 @@ The Pipeline wrapper never scans a Box. Binding ends with the run; the reusable remains subject to Runtime idle expiry. Persistent workspace files survive expiry, but processes and container-local state do not. -Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. +Box is the optional sandbox subsystem used by native execution tools, stdio MCP servers, agent-side skill authoring, and managed processes. Skill storage and read-only access are Core responsibilities and remain available without Box. 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/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic 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. +- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the + Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py` + and execution-backed tools depend on Box availability. +- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box. Core's registry points to immutable content-addressed publications, and every update carries `base_revision` so concurrent writers fail instead of overwriting each other. +- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do. Durable Box Workspace storage is shared across placement generations, but sandbox sessions and managed processes are generation-scoped. LangBot validates @@ -208,11 +211,42 @@ retires stale processes and closes already-attached relays. 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. +- `src/langbot_plugin/box/runtime.py` owns sandbox sessions, generic read-only mounts, 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. +- `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Activation + pins the first revision for a run in Host-owned run ledger metadata; instructions, + resource reads, execution mounts, and revision-scoped dependency state use that + exact publication. A new run refreshes the Workspace catalog and resolves the + current revision, ignoring old conversation activation state. Lost in-memory + pins in an active authorized run are reloaded from its ledger. After a Core + process restart, expired run sessions cannot call tools: the Host rejects them + rather than silently resolving a new version. This does not add automatic run resumption. Core mounts only activated and authorized Skills. Published + trees are never agent write targets: authoring happens in Workspace drafts and + an explicit registration/update atomically publishes the next revision. Core + turns those package roots and content digests into generic read-only mounts; + Box does not understand Skill names, metadata, revisions, files, or CRUD. -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. +Skill execution paths include the publication digest (`/opt/langbot-artifacts/`). +Docker/Podman mount a read-only cache when creating the Box and atomically add +verified packages before execution; E2B seals and verifies a private staging copy +before publishing a stable projection. nsjail applies the requested mounts in a +separate namespace for each execution. Activating a Skill after acquiring a Box +therefore does not recreate the session or stop its managed processes. Ordinary +host mount changes still fail explicitly on fixed-mount backends. + +A shared Box remains a Workspace filesystem sharing boundary: its commands can +see previously materialized packages. Run pins prevent revision switching; they +are not access isolation between mutually untrusted programs in the same Box. +Use package-relative script paths or the path returned by activation. Core tools +translate legacy `/workspace/.skills/` command paths to the run's digest +path, but do not rewrite paths embedded inside scripts. Published packages remain +read-only; use writable drafts and the existing per-run outbox for output. + +Skill storage uses `skills.root`. Box execution config lives under `box:`: +`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old +`box.local.skills_root` key is read only as an online-upgrade fallback and is +marked for removal in the next major version. Start LangBot with +`--standalone-box` when connecting to an externally launched Box runtime. ## HTTP API, Web UI, and MCP Server @@ -233,12 +267,15 @@ Persistence is centered on `pkg/persistence/mgr.py`. - SQLite is the default database; PostgreSQL is supported. - Models live under `pkg/entity/persistence/`. +- `pkg/persistence/media.py::MediaReferenceText` validates bound conversation payloads on ORM/Core writes while preserving the existing TEXT schema and string API. It rejects recognized structured inline images: retained images go through `MediaCache.externalize_chain_dump()` before serialization, while metadata-only snapshots and transcripts use `redact_inline_images()`. Reads and comparisons preserve historical values for incremental cleanup. Runtime/adapter messages, arbitrary diagnostic text, and opaque strings are outside this contract. - Timezone-less SQL `DateTime` columns store UTC-naive values on both backends. Normalize aware values with `pkg/persistence/datetime_utils.py::as_naive_utc` at the bind boundary, including deadlines, leases, event times, and query/retention cutoffs; do not merely strip an offset. Existing naive rows represent UTC. Restore UTC awareness for application comparisons and epoch serialization. Runner ledger/event/transcript stores follow this existing schema contract without a data migration or changes to journal/tenant authorization. - Fresh schemas are created from current metadata, then Alembic migrations run to head. LangBot 4.x does not upgrade 3.x databases. - New schema changes use Alembic under `pkg/persistence/alembic/versions/`; there is no legacy migration chain in 4.x. 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`. +Storage maintenance applies `storage.media_cache.retention_days` to both CAS files and historical inline images. Inline image expiry uses the owning record's creation time and preserves text, URLs, and record identity. It does not backfill CAS. `pkg/storage/inline_media_cleanup.py` processes one payload row at a time, with a per-table limit of 100 indexed candidates or 8 MiB of JSON characters per maintenance pass (one oversized row may exceed the latter limit). Run events lack a Workspace column, so their candidate cursor also advances past foreign or missing records; payloads are loaded only after checking Workspace ownership. Durable Workspace metadata cursors and timestamp/identity indexes keep completed history out of subsequent scans; newly written history must retain its actual creation timestamp. Updates and cursor advancement commit together. Invalid historical JSON is preserved. SQLite pages become reusable after payload removal; the database file is not automatically vacuumed by this task. + ## 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. diff --git a/Dockerfile.acceptance b/Dockerfile.acceptance new file mode 100644 index 000000000..3fa1250a2 --- /dev/null +++ b/Dockerfile.acceptance @@ -0,0 +1,2 @@ +FROM local/langbot-oss-411:688e7761 +COPY src/langbot/pkg/persistence/databases/sqlite.py /app/src/langbot/pkg/persistence/databases/sqlite.py diff --git a/Makefile b/Makefile index c057a7687..3b54cf441 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,9 @@ test-integration-fast: test-coverage: bash scripts/test-coverage.sh -# Full local quality gate (quick + integration + coverage) +# Full local quality gate (lint + unit/smoke/fast integration coverage) test-all-local: - bash scripts/test-quick.sh - bash scripts/test-integration-fast.sh + uv run ruff check src/langbot/ tests/ --output-format=concise bash scripts/test-coverage.sh # Run linting only @@ -33,4 +32,4 @@ lint: # Fix linting issues lint-fix: ruff check --fix src/langbot/ tests/ - ruff format src/langbot/ tests/ \ No newline at end of file + ruff format src/langbot/ tests/ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cb5f1c88e..5cd3ad0f9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -22,13 +22,22 @@ services: - LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING=${LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING:-128} - LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE=${LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE:-4} command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"] + # The control WebSocket port also serves an unauthenticated GET /healthz + # (see langbot_plugin runtime/io/controllers/ws/server.py). curl is removed + # from the image, so probe with the always-present python. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5400/healthz', timeout=3).getcode()==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s 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 — + # up``). With Box off, LangBot keeps skill management, activation, and + # read-only resources available but disables execution tools 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: @@ -65,6 +74,16 @@ services: # `-s` / `--stdio-control` only for the stdio mode LangBot uses outside # containers. command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "box"] + # Box serves GET /healthz (liveness) and GET /readyz (readiness, 503 until + # the configured backend and isolation guarantees are ready) on the control + # port (default 5410). Use /readyz so the container is only reported healthy + # once it can actually accept sandbox work. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5410/readyz', timeout=3).getcode()==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s networks: - langbot_network @@ -73,6 +92,10 @@ services: container_name: langbot volumes: - ./data:/app/data + # Core owns the SkillRepository even when the Box profile is disabled. + # Keep this path identical to langbot_box so optional execution can + # consume the same Workspace-scoped package revisions. + - ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box} restart: on-failure environment: - TZ=Asia/Shanghai @@ -92,12 +115,23 @@ services: # 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 + # TODO(next-major): default LANGBOT_SKILLS_ROOT to ${PWD}/data/skills + # after the historical Box storage path no longer needs zero-copy upgrades. + - SKILLS__ROOT=${LANGBOT_SKILLS_ROOT:-${LANGBOT_BOX_ROOT:-${PWD}/data/box}/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 + # The core Quart app exposes an unauthenticated GET /healthz on api.port + # (see langbot/pkg/api/http/controller/main.py). curl is removed from the + # image, so probe with the always-present python. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5300/healthz', timeout=3).getcode()==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s networks: - langbot_network diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 7ce145610..53a629e20 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -214,8 +214,9 @@ spec: # 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 +# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill +# activation, resources, and management remain Core-owned without Box. Box 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. # @@ -448,14 +449,15 @@ spec: key: token # 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. + # Deployment's box-root mountPath, so generic package mount 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: SKILLS__ROOT + # TODO(next-major): use /app/data/skills after the legacy path window. + value: "/app/data/box/skills" - name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS value: "/app/data/box" volumeMounts: diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md index 3eac9eb10..256da5f45 100644 --- a/docs/API_KEY_AUTH.md +++ b/docs/API_KEY_AUTH.md @@ -360,3 +360,42 @@ curl -X POST \ - API-key-enabled endpoints use the same resource shapes as the web UI - No need to learn different API paths - use the existing API documentation with API key authentication - API keys never select a Workspace from a request header; their persisted binding is authoritative + +## Workspace assistant browser session + +The Workspace assistant endpoints under `/api/v1/assistant` are account-session +features, not API-key/MCP automation tools. They require a Workspace-scoped user +token and `runtime.operate` for recommendation and turns. Conversation reads +also enforce account ownership. + +- `GET /recommended-model` resolves and validates the recommended tool-capable + model when the panel opens. An explicit `model_uuid` in a turn always takes + precedence; it never silently falls back if unavailable. +- `POST /conversations/{id}/turn/stream` accepts the same revision, text/model, + or approval decision as `/turn`, and returns NDJSON. `snapshot` frames contain + the public conversation; `text` frames contain the cumulative text for the + current model invocation; `phase` frames identify the model round or running + tool. A snapshot replaces transient text/tool progress. `completed` contains + the final conversation (including approval or failed states); `error` is a + transport/admission failure. Blank lines are heartbeats. +- `GET /conversations` lists the latest 100 conversations owned by the current + account in this Workspace. `GET /conversations/{id}` includes transient + `progress` (cumulative text, round and running tool) while execution is active. +- Disconnecting a streaming response only detaches the browser. The backend owns + the task until completion, approval, timeout or explicit stop. Slow or absent + consumers never block execution; reconnect using the conversation snapshot. +- `POST /conversations/{id}/stop` accepts `{ "revision": N }` for the active turn. + It enforces ownership and revision, cancels execution and records `stopped`. + Stopping cannot roll back tool effects and clients must never replay a write + automatically. Writes still require confirmation of saved arguments. +- Background execution lives in the current backend process, with at most four + simultaneous turns and the existing 120-second turn limit. This is not a durable + job queue: backend restarts do not resume interrupted tasks. + +The original JSON `/turn` endpoint remains compatible for existing callers. + +The assistant recommendation uses Space `/api/v1/models/assistant/recommendation`, +which selects only from the assistant pool using fresh successful health checks. +An unavailable recommendation returns 503 without falling back to general chat rankings. +The browser checks Workspace model providers before mounting the assistant; an empty +provider list shows Account sign-in and model settings actions instead of a composer. diff --git a/docs/DEPENDENCY_CONTRACT_TESTS.md b/docs/DEPENDENCY_CONTRACT_TESTS.md new file mode 100644 index 000000000..6c038c99f --- /dev/null +++ b/docs/DEPENDENCY_CONTRACT_TESTS.md @@ -0,0 +1,133 @@ +# Provisioned dependency contracts + +`.github/workflows/optional-contracts.yml` runs three isolated jobs for optional +product dependencies. Every job provisions its dependency and rejects missing, +skipped, failed, or errored required cases. These are tests of actual integration +boundaries; removing them or substituting fake external implementations would +lose coverage. No production credentials or external model accounts are needed. + +The fixtures are deliberately pinned for reproducibility. They are not a promise +that the latest release of every external repository passes. Update pins only +after rerunning the relevant contracts. This workflow is separate from the +latest-LocalAgent Space-resolution work in PR #2632. + +## Official plugin identity boundary + +The source lock in `tests/fixtures/legacy_plugin_identity_sources.json` records +the official repository, immutable revision, and SHA-256 of the consumed Python +source files. CI checks out that revision with read-only access +and no persisted Git credentials. Tests verify file digests before executing the +actual identity methods, their genuine configuration-error classes, and their +real `scoped_identity` dependency. Vendor HTTP clients are not constructed. + +The original test expected bare upstream user IDs and extracted only one method +from a hard-coded sibling checkout. Supported current plugins intentionally +namespace IDs by Workspace or trusted installation, so merely provisioning that +old test produced false failures. The current fixture is: + +- Official repository: `langbot-app/langbot-plugins` +- Revision: `7ad54b1a139d88113bc55128326800ed29779737` +- DifyAgent 0.2.3; N8nAgent, CozeAgent and TboxAgent 0.2.2 +- All exceed Core's current migration minimums (0.1.10; TboxAgent 0.1.8) + +The 19 Host provenance/resume tests retain their exact raw-identity assertions. +The consumer boundary adds dedicated/shared profiles to the former eight cases: +16 real-plugin cases verify the independently specified session/event/bot source, +current documented namespace, and rejection of missing trusted identity. Shared +cases use the real SDK `bind_invocation` and `InstallationBinding`. They are not +full plugin-installation, vendor API, or migration-admission tests. + +The documented semantic change is in each pinned plugin's `SHARED_RUNTIME.md`, +for example [Dify's upstream identity migration](https://github.com/langbot-app/langbot-plugins/blob/7ad54b1a139d88113bc55128326800ed29779737/Runner/dify-agent/SHARED_RUNTIME.md#upstream-identity-migration). +An older bare-ID source revision also passed the old tests, but its manifest +versions are below Core's migration minimums. It is intentionally not used to +claim current compatibility. + +To run with an official checkout at the locked revision: + +```bash +export LANGBOT_LEGACY_PLUGIN_ROOT=/absolute/path/to/langbot-plugins +uv run --frozen pytest --required-suite plugin_identity -q -rs +``` + +An unset variable allows an explicit local prerequisite skip. An empty variable, +a missing file, a path escaping the fixture root, or a hash mismatch fails. +CI selects the `plugin_identity` suite and requires every selected case to pass without skips. + +## Embedded SeekDB + +The job installs `uv sync --dev --extra seekdb --frozen`, using the versions and +wheel hashes already committed in `uv.lock`: pyseekdb 1.4.0.post1 and +pylibseekdb 1.4.0. It runs the existing two slow tests against a real embedded +engine, covering upsert/text preservation and full-text/hybrid relevance order. +The tests supply their own small embedding vectors; no model download is needed. + +```bash +uv sync --dev --extra seekdb --frozen +uv run --no-sync pytest --required-suite seekdb \ + -q -rs --durations=5 --junitxml=seekdb-contract-results.xml +``` + +This isolated Linux job keeps native dependency startup failures visible instead +of treating them as skips. JUnit results and native startup logs are uploaded +when available. A missing optional package or native engine failure is not +counted as a passing contract. +## Compiled CLI HTTP contract + +The `Compiled CLI HTTP Contract` CI job runs the real `lbctl` process against a +loopback-bound Core HTTP server with temporary SQLite data and synthetic runtime +providers. It checks command parsing, API-key/workspace and capability discovery, +knowledge-engine/parser routes, sandbox diagnostics, pipeline extension updates, +pipeline execution, and monitoring reads. It does not need production accounts, +external model services, Docker, or a running LangBot deployment. + +CI builds the official [`langbot-app/langbot-cli` v0.2.0 source](https://github.com/langbot-app/langbot-cli/tree/3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9), +pinned to commit `3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9`, using Go 1.26.8. +Module versions and checksums come from that checkout's `go.mod` and `go.sum`. +The job verifies modules and builds with `CGO_ENABLED=0`, `-mod=readonly`, and +`-trimpath`. It requires the CLI HTTP behavior contract and rejects skips. + +To run locally with an official pinned CLI checkout and Go 1.26.8: + +```bash +# In the official langbot-cli checkout: +git checkout 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9 +export GOTOOLCHAIN=local CGO_ENABLED=0 +go mod download +go mod verify +go build -mod=readonly -trimpath -o ./bin/lbctl ./cmd/lbctl +export LANGBOT_CLI_BIN="$(pwd)/bin/lbctl" + +# In the LangBot Core checkout: +uv run --frozen pytest --required-suite compiled_cli -q --tb=short -rs +``` + +Without `LANGBOT_CLI_BIN`, this test remains an explicit local skip. When set, it +must point to a real compiled executable. CI supplies an absolute path and treats +a missing binary, command failure, or skipped case as a failure. Update the source +pin deliberately and rerun the contract before accepting a newer CLI version. + +## Required suite policy + +CI provisions dependencies and invokes `pytest --required-suite SUITE`. The +registered suites are `postgres`, `local_agent`, `security`, `compiled_cli`, +`plugin_identity`, and `seekdb`. Tests carry explicit suite markers; mixed +SQLite/PostgreSQL fixtures mark only their PostgreSQL parameter. New marked +cases are discovered automatically, without editing workflow file lists or counts. + +`tests/utils/required_suites.py` lists the stable behavior obligations for each +suite. `@pytest.mark.contract('name')` identifies the behavior a case exercises. +Only a new or retired behavior obligation changes this policy; renaming, moving, +or parameterizing an existing test does not. Unmarked sibling tests are not run +by a required suite. + +Required execution fails if a behavior obligation is absent, no cases are +selected, or any selected case fails, errors, skips, or xfails (including fixture +setup/teardown). Partial `-k`, `-m`, and `--deselect` filters are rejected. +`--collect-only` validates selection without claiming execution. Without +`--required-suite`, optional local prerequisites may still skip as before. +JUnit remains an artifact, not a second source of the expected test inventory. + +These checks prevent missing execution; they cannot prove that a behavior marker +is truthful or that an assertion detects defects. Review and mutation checks +still establish assertion quality. diff --git a/docs/LLM_LATENCY_BENCHMARK.md b/docs/LLM_LATENCY_BENCHMARK.md new file mode 100644 index 000000000..c88f4ddfd --- /dev/null +++ b/docs/LLM_LATENCY_BENCHMARK.md @@ -0,0 +1,100 @@ +# LLM request-layer benchmark (2026-10-15) + +This controlled test did not reproduce a large LangBot slowdown. Warm request +preparation was sub-millisecond; most measured latency occurred upstream. +It did identify two local transport problems, now fixed in this PR. + +## Method + +One Windows host, Python 3.12.7, LiteLLM 1.88.1 and OpenCode 2.0.16 used the +same user-provided NewAPI gateway and requested `claude-haiku-4.5`. Each client +made one warmup and six measured streaming calls, alternating all six client +orders. The synthetic system prefix was about 6,000 tokens. Parameters were +`temperature=0`, `max_tokens=256`, `stream=true`, `include_usage=true`, with an +ephemeral cache marker on the system text. No reasoning budget was requested. + +The measurement relay deliberately forwards identical serialized requests and +headers for all three clients. It replaces OpenCode's added agent/environment +instructions and normalizes SDK request differences. The canonical SHA-256 is +`7834a66a188e70b4eb1b6234b3a14be4c66b8c72dd8a2c06395fe2505c1c371b`. +OpenCode runs as an isolated resident server, using its +[versioned session API](https://github.com/anomalyco/opencode/blob/v2.0.16/packages/protocol/openapi.json). +Process startup and session creation are outside the warm dispatch measurement. + +All clients share the relay's upstream connection pool. Therefore this experiment +isolates local request dispatch and upstream response timing; it does **not** +compare their independent direct-to-provider TLS/proxy behavior. It also excludes +the complete bot pipeline, Runner/plugin RPC and OpenCode UI rendering. It cannot +establish which application is faster in every production deployment. + +## Results + +Medians, milliseconds; six measured samples per client: + +| Client | Dispatch to relay | First content | Complete upstream response | +| --- | ---: | ---: | ---: | +| Direct HTTPX | 3.952 | 1119.541 | 2199.413 | +| LangBot Core requester | 6.802 | 1089.092 | 2135.935 | +| OpenCode | 41.158 | 1104.020 | 2148.265 | + +Dispatch includes client preparation and local HTTP dispatch. First content and +completion use the same relay boundary for all clients, measured from dispatch; +they are not UI latency. LangBot's separately instrumented preparation median +was **0.316 ms**, maximum **0.497 ms**. These small samples and upstream variation +do not justify interpreting the small TTFT differences as a speed ranking. + +Every measured response reported 5,990 input tokens, 5,987 cache-read tokens and +109 output tokens. This demonstrates equal warm cache accounting under the +normalized upstream request, not native cache-marker forwarding by all clients. +No cache-miss latency comparison or independent provider-route verification was +performed. Missing cache accounting is represented as null, never assumed zero. +The gateway may perform its own routing, retries and cache translation. + +Sanitized individual samples and summaries are in +[llm-latency-2026-10-15.json](llm-latency-2026-10-15.json). + +## Bottlenecks fixed + +1. The OpenAI SDK stops consuming SSE at `[DONE]` before HTTP framing reaches + EOF. The healthy connection was then discarded. In the baseline relay run, + LangBot reused zero of six measured client connections; after bounded terminal + draining it reused six of six. A real TCP regression test also reproduces this + with split terminal markers and late usage. OpenRouter's generic adapter + already consumed EOF. Early close and cancellation still close immediately. +2. On this host, constructing default HTTPX clients took 1,761 / 1,443 / 1,435 ms. + Loading one TLS context per requester pool and sharing it across that client's + proxy mounts reduced measured pool setup to 666 / 430 / 431 ms. Certificate + loading now runs off the event loop. These are local setup measurements, not + a claim of equivalent percentage improvement in LLM latency. Runtime lazy + initialization and process startup remain separate cold costs. +3. High-resolution monotonic timing replaces the low-resolution clock used on + Python 3.12 Windows. Preparation no longer misleadingly rounds to zero. + `transport_setup_ms` exposes pool initialization separately. + +The relay socket observations concern client-to-relay connections. HTTPX and +OpenCode have different keepalive policies, so those counts alone are not a +cross-client pooling ranking. The shared relay also prevents quantifying the +provider-side handshake savings from the SSE fix in this run. + +## Reproduce + +From a development environment with LangBot dependencies installed, set +`LLM_BENCHMARK_API_KEY` securely in the environment, then run: + +```sh +python scripts/llm_latency_probe.py --opencode /path/to/opencode \ + --opencode-version 2.0.16 --base-url https://gateway.example/v1 \ + --model claude-haiku-4.5 --long-prefix --cache-control --rounds 6 \ + --output /path/outside/repository/latency.json +``` + +This makes 21 billable requests by default. `--mock` uses synthetic local SSE +instead; it still needs OpenCode. `--baseline-no-drain` disables terminal draining +for a transport regression comparison. The harness records only hashes and +message sizes, not prompt bodies or credentials, and terminates its own isolated +OpenCode process. Keep raw results outside the repository because they include +the configured gateway URL. Other OpenCode versions may need API adjustments. + +The next diagnostic for a reported slow production session should capture the +actual pipeline/Runner timing, streaming mode, prompt size, reasoning settings, +cache usage and gateway routing alongside these requester-level measurements. diff --git a/docs/LLM_REQUEST_E2E.md b/docs/LLM_REQUEST_E2E.md new file mode 100644 index 000000000..21bad844f --- /dev/null +++ b/docs/LLM_REQUEST_E2E.md @@ -0,0 +1,108 @@ +# Request-chain acceptance (2026-10-15) + +The acceptance suite starts an isolated Core process, a real SDK Plugin Runtime +process, and a packaged Runner plugin. It installs the plugin and creates the +provider, model and Agent through the same authenticated HTTP APIs used by the +application. Agent Debug requests then traverse: + +HTTP -> Core Agent orchestration -> Plugin Runtime -> plugin SDK -> Runtime -> +Core model manager/request policy -> LiteLLM/provider SDK -> HTTP/SSE provider. + +Assertions cover plugin return values, captured provider requests, TCP disconnects +and persisted SQLite monitoring records. No requester, action transport, model +manager or monitoring implementation is replaced with a mock. + +The remote provider is a deterministic local HTTP server. It injects failures and +delays reproducibly; these are real process/network integration tests, **not** a +claim that every external provider has been tested. See the separate +[latency benchmark](LLM_LATENCY_BENCHMARK.md) for the live NewAPI experiment. + +## Matrix + +Measured results on Windows / Python 3.12.7 / LiteLLM 1.88.1: + +- Core with SDK companion commit `c25ff9b`: **31 passed**, strict stream closure, + including all four historical API cases (106.30 seconds). +- Core with the existing SDK 0.7.11rc5 environment: **27 passed, 4 XFAIL** for + the reproduced SDK cancellation defect (107.61 seconds). +- Published stable SDK 0.7.11 also reproduced the cancellation defect in the + preceding 23-case run. The old and stable wrappers were verified identical. +- Focused Core regressions: **371 passed** on Python 3.13.7, ResourceWarnings fatal. +- SDK API and stream flow-control regressions: **507 passed**. +- Existing real Runner discovery/orchestration/event-processor E2E: **3 passed**. + +| Area | Cases and evidence | +| --- | --- | +| Public APIs | Runner and invocation-bound plugin APIs: message, message with usage, chunk stream and event stream; unchanged SDK entities and nested usage | +| Historical plugin APIs | Verbatim SDK 0.5.8 proxy and provider-message entities, all four APIs, ordinary calls without `run_id` | +| Request configuration | Persisted defaults, call overrides, OpenAI cache key/retention, OpenRouter numeric reasoning budget and Anthropic cache marker | +| Headers | Explicit application attribution and scoped conversation ID reach the provider | +| Multimodal/tools | Image data URL and function schema survive plugin serialization and provider conversion | +| Retry/failure | 503 then success, `num_retries=0`, `max_retries=0`, 401 without retry, first-event timeout, total timeout, idle timeout and connection loss after content | +| Recovery | Each fault followed by a successful request through the same running processes | +| Cancellation | Early close of Runner and plugin APIs, both chunk and event streams; provider socket closes and the next invocation succeeds | +| Reasoning | Reasoning content shown/hidden with the existing `remove_think` option; unsupported numeric budget rejected before HTTP | +| Monitoring | Full cache/reasoning usage, transport timing and structured errors persisted | + +Historical API files come from SDK tag `0.5.8`, commit +`41e320b68e899a7b5e74cb48c5246f6ccbf9d73f`. +They use their own message serializers and the current Runtime transport. This +checks old plugin call/response compatibility; it is not a test of running the +entire old Runtime against the current Core. + +## Defects exposed by the full chain + +- The plugin bridge flattened model and call policy layers, allowing persisted + `request_policy.retries` to override a call's legacy zero-retry option. LLM + policy layers are now resolved separately; unrelated argument merging remains. +- Ordinary plugin calls without a Runner/query returned content but lost usage. + Core now creates a trusted, invocation-local query for accounting, using the + existing plugin-action query builder. No public payload/signature changes. +- The shipped OpenRouter requester uses OpenAI metadata. Numeric budgets selected + the wrong mapping, and LiteLLM's OpenAI chat adapter stripped cache markers. + OpenRouter chat now uses its own protocol adapter. Embedding/rerank routing is + unchanged, and a regression test reads the actual shipped requester YAML. +- Published SDK 0.7.11 leaves inner streaming generators unowned in several public + proxy wrappers. Core's direct TCP close test passes, but the complete plugin + chain does not promptly cancel its upstream request. The companion + [SDK PR #177](https://github.com/langbot-app/langbot-plugin-sdk/pull/177) adds + explicit generator ownership without changing signatures, permissions, action + payloads or error translation. + +**Immediate remote cancellation requires SDK 0.7.12 or later.** Core now pins +published SDK 0.7.12, which contains the companion fix. Separately deployed +Runtime and plugin development environments also need the fixed SDK. The E2E +suite reports the known close failure as XFAIL only for SDK <=0.7.11. Set +`LLM_E2E_REQUIRE_STREAM_CLOSE=1` to require strict passing with the patched SDK; +newer SDK versions must pass without this override. Historical API checks are +explicitly skipped unless their released source files are supplied. + +## Reproduction + +```sh +python -m pytest tests/e2e/test_llm_request_chain.py -q --tb=short +``` + +For the historical API matrix, export the two unmodified files below from SDK tag +`0.5.8` into a temporary directory, named `legacy_api.py` and `legacy_message.py`, +then set `LLM_E2E_LEGACY_API_DIR` to its absolute path: + +- `src/langbot_plugin/api/proxies/langbot_api.py` +- `src/langbot_plugin/api/entities/builtin/provider/message.py` + +For strict cancellation acceptance, install the companion SDK into an isolated +test environment and set `LLM_E2E_REQUIRE_STREAM_CLOSE=1` before running the suite. +The existing CI E2E job discovers this test module automatically. Tests use random +localhost ports, test-only credentials and pytest temporary data directories; +they leave application data and running user instances untouched. + +## Limits + +The suite does not exercise IM delivery, browser rendering, external provider +authentication/billing, native Anthropic/Gemini network services, Codex OAuth, or +complete third-party Runner business logic. It validates multimodal/tool request +transport rather than image understanding or actual tool execution. Native Codex, +retry, reasoning, transport and plugin regressions also run in the focused Core +test suite. A Python 3.13 Windows full-app attempt timed out during startup before +any LLM request; full process acceptance therefore uses Python 3.12.7, while the +focused Core regressions run on Python 3.13.7. diff --git a/docs/LLM_REQUEST_POLICY.md b/docs/LLM_REQUEST_POLICY.md new file mode 100644 index 000000000..58ebf63ef --- /dev/null +++ b/docs/LLM_REQUEST_POLICY.md @@ -0,0 +1,256 @@ +# LLM request policy and diagnostics + +The Core owns chat-call deadlines and retry decisions. The existing SDK +`Message`, `MessageChunk`, invoke-with-usage and stream-usage shapes are unchanged. +No SDK or Runner upgrade is required. This policy does not add automatic tool +replay, model fallback, key rotation, or embedding/rerank retries. + +The public API remains compatible with existing plugins. Full-chain testing +identified a pre-existing SDK <=0.7.11 early-close defect: prompt remote +cancellation through public plugin/Runner wrappers requires SDK 0.7.12 or later, +which includes the [SDK fix](https://github.com/langbot-app/langbot-plugin-sdk/pull/177). +Core now pins 0.7.12; separately deployed Runtime and plugin development +environments must also install a version containing the fix. +See [E2E acceptance results](LLM_REQUEST_E2E.md) for tested versions and scope. + +## Configuration + +Instance defaults live in `data/config.yaml` under `llm_request_policy`; restart +Core after editing them. Persist model-specific overrides in the existing model +advanced parameters (`extra_args`). Runners can override them through their +existing call-level `extra_args` argument: + +```json +{ + "request_policy": { + "retries": 1, + "total_timeout": 180, + "first_event_timeout": 60, + "stream_idle_timeout": 45, + "connect_timeout": 10, + "read_timeout": 120, + "write_timeout": 30, + "pool_timeout": 10, + "backoff_base": 0.5, + "backoff_max": 8, + "attribution": true, + "conversation_header": null + } +} +``` + +Precedence is instance/requester defaults, model extra arguments, then call extra +arguments. Within each layer, `request_policy.retries` overrides legacy +`num_retries` / `max_retries`. Zero disables retries. Legacy numeric `timeout` +sets read, first-event and idle timeouts; it does not extend the total deadline. +Timeouts are positive finite seconds; retries must be an integer from 0 to 5. +Unknown policy keys are rejected. Policy keys are removed before calling upstream. + +Default retries are **zero**, total timeout is 300 seconds, and first-event and +idle timeouts are 120 seconds. Retry backoff uses exponential full jitter; +`Retry-After` seconds or HTTP dates are respected only within the remaining total +budget. Quota, authentication and invalid-request failures are not retried. +Core disables both LiteLLM and provider-SDK retries for managed calls. A gateway +may still retry internally; configure its budget separately. + +Retries stop after any raw upstream event (including reasoning/control/usage) +or, for legacy adapters without raw instrumentation, the first yielded chunk. +Cancellation is propagated and the owned stream is closed. Total deadlines also +include downstream consumption time, but Core does not cancel a consumer while +the generator is suspended at a yield; expiration is enforced on the next pull. +Transport connect/read/write/pool limits apply to LiteLLM and native Codex HTTP. +Legacy third-party requesters receive Host-level deadlines but remain responsible +for their internal transport configuration and internal retries. + +## Diagnostics and usage + +HTTP monitoring and `get_monitoring_records(kind="llm_calls")` return additive +`details` containing `timing`, complete provider `usage`, and structured `error`. +Older records have null details. Existing top-level token counters remain intact. +Migration `0038_llm_call_details` adds a nullable JSON column and preserves rows. + +Timing offsets use a high-resolution monotonic clock, in milliseconds from invocation start: + +| Field | Meaning | +| --- | --- | +| `request_prepared_ms` | First attempt finished building its request | +| `request_started_ms` | First adapter call into the network SDK; not a socket-level timestamp | +| `response_open_ms` | SDK returned a response/stream object | +| `first_event_ms` | First parsed upstream stream event, including reasoning/control | +| `first_reasoning_ms` | First upstream reasoning delta when supported | +| `first_content_ms` | First upstream content delta; inline think tags may still be content | +| `first_visible_chunk_ms` | First nonempty normalized chunk; may include displayed reasoning | +| `response_completed_ms` | Host finished consuming the response | +| `total_ms` | Invocation duration, excluding the subsequent monitoring write | +| `attempts`, `retry_wait_ms` | Host attempts and total backoff wait | +| `preparation_ms` | Cumulative LiteLLM request-building time across attempts | +| `transport_setup_ms` | Cumulative managed HTTP pool acquisition, including first-use TLS setup | +| `preparation_cache_hits` | Message preparation reused within a retrying invocation | + +Missing fields mean not observed or not instrumented, not zero latency. The Core +does not label these measurements as network-only TTFT. Usage preserves nested +cache and reasoning counts, costs and provider-specific counters when supplied; +missing upstream accounting cannot be reconstructed. Partial usage received +before cancellation/failure is retained. No fabricated usage event is sent early. + +`RequesterError` retains its legacy string representation and adds `code`, +`status_code`, `retryable`, `retry_after`, `request_id`, `phase`, and `as_dict()`. +Native Codex errors retain their existing ValueError/HTTP error contract. +Legacy SDK error transport remains unchanged; structured diagnostics are available +in Core monitoring. The metadata never copies upstream error bodies or headers +wholesale. Requester-specific raw error strings are not included by the LiteLLM +error translator. + +## Application and conversation headers + +Direct OpenRouter requests automatically receive: + +```text +HTTP-Referer: https://langbot.app +X-OpenRouter-Title: LangBot +x-session-id: lb- +``` + +Existing headers in model/call `extra_headers` win, case-insensitively. Legacy +`X-Title` is respected. Set `attribution: false` to disable automatic application +attribution (explicit headers remain untouched). Detection uses the exact +`openrouter.ai` hostname, or an OpenRouter requester without an overridden base +URL; arbitrary gateway hosts do not inherit OpenRouter identity headers. + +The conversation hash includes Workspace, bot, pipeline and session identity; +it is stable across turns and differs across those scopes. It is pseudonymous, +not a claim of cryptographic anonymization. Calls without conversation context +omit generated conversation headers. No prompt, raw user ID or group ID is sent. + +`x-langbot-conversation-id` is a custom convention, **not a documented universal +provider standard**. OpenRouter documents `x-session-id` (or body `session_id`, +which takes precedence). LiteLLM Proxy documents `x-litellm-session-id` and +vendor session headers. Configure `conversation_header` explicitly for gateways: + +```json +{ + "request_policy": {"conversation_header": "x-litellm-session-id"}, + "extra_headers": { + "HTTP-Referer": "https://langbot.app", + "X-OpenRouter-Title": "LangBot" + } +} +``` + +Supported generated header names are `x-session-id`, `x-litellm-session-id` and +`x-langbot-conversation-id`. The latter is intended for a gateway configured to +consume that custom header; do not expect automatic routing or caching benefits. + +New API may supply its own OpenRouter attribution and may filter/override client +headers. Configure explicit channel forwarding/override rules and verify the +outgoing request. Sending a header to New API is not proof OpenRouter received it. +The local `langbot-new-api` snapshot inspected during implementation overwrites +attribution with New API; upstream main uses defaults when its outgoing headers +are empty. This change does not modify or deploy either gateway. + +Sources checked on 2026-10-15: + +- https://openrouter.ai/docs/app-attribution +- https://openrouter.ai/docs/guides/best-practices/prompt-caching +- https://docs.litellm.ai/docs/proxy/request_headers +- https://docs.litellm.ai/docs/routing +- https://github.com/QuantumNous/new-api/blob/main/relay/channel/openai/adaptor.go +- https://github.com/QuantumNous/new-api/blob/main/relay/channel/api_request.go + +## Connection and preparation reuse + +Initialized OpenAI-compatible, OpenRouter and native Codex requesters own a lazy +HTTP pool per runtime Provider. Keys, headers and timeout budgets stay request-local. +The pool holds at most 100 connections, with 20 idle keepalive connections and a +30-second idle expiry. Provider retirement rejects new leases and closes its pool +after existing streams finish or are cancelled. Native LiteLLM providers retain +their own transport handling; custom LiteLLM transport/TLS settings, explicit caller clients and redirected model routes +are preserved. Bare OpenAI names on OpenRouter retain LiteLLM's alternate routing; +use OpenRouter's fully qualified names such as `openai/gpt-4o`. + +Each pool loads its TLS context once in a worker thread, preserving HTTPX +certificate and proxy environment settings. Concurrent initialization is serialized; +cancellation or retirement during certificate loading creates no HTTP client. + +OpenAI-compatible streams drain HTTP framing after the SSE `[DONE]` record +(at most 50 ms / 64 KiB) so the SDK can return healthy connections to the pool. +Early close and cancellation never trigger this drain; late usage preceding +`[DONE]` remains available. OpenRouter already consumes EOF in its generic adapter. +Codex briefly drains the HTTP tail after a completion event (at most 50 ms) to +release a healthy connection for reuse. Incomplete/cancelled streams are closed. +Capability lookups use a bounded requester-local cache (128 entries, five minutes). +Message/tool conversion is reused only within an invocation that permits retries, +with independent copies for each attempt. No conversation content is cached across +invocations. Token counting runs in the application's executor instead of blocking +the event loop. These reduce local overhead; they do not guarantee faster upstream +inference or a cache hit. + +## Unified thinking budgets + +Existing effort levels and Runner/SDK signatures remain unchanged. Model +`reasoning_config` accepts an optional positive integer `budget_tokens`: + +```json +{"reasoning_config": {"level": "enabled", "budget_tokens": 2048}} +``` + +Old SDKs can send the same object inside their existing call `extra_args`. +Precedence is persisted model configuration, model `extra_args.reasoning_config`, +call `extra_args.reasoning_config`, then the existing Runner override. Each object +replaces the previous one; a Runner effort override clears a lower-priority budget. +Omitting `level` with a budget means `enabled`. Budgets cannot be combined with an +effort level, `disabled`, or native thinking arguments. Unsupported budgets fail +before the network call; they are never silently converted into an effort level. + +Numeric budgets map to Anthropic `thinking.budget_tokens`, Gemini 2.5 thinking +configuration, supported Qwen `thinking_budget`, or OpenRouter +`reasoning.max_tokens`. Direct OpenAI/Codex and adaptive-only Claude models keep +effort-based control. Query `reasoning_capabilities.budget_tokens` for the Core's +accepted bounds; upstream models/gateways may impose additional limits. OpenRouter +may translate numeric budgets to effort for models without native numeric support. +Direct Anthropic calls require an explicit `max_tokens` greater than the budget; +Core never silently raises the output limit. Existing UI effort selectors are +unchanged; numeric budgets can be configured through the model API or extra args. + +## Prompt caching + +Default behavior remains provider-controlled. `extra_args.prompt_caching` is a +Core-only object and is stripped before sending the request. Call-level objects +replace model-level objects. There is no local generated-response cache. + +```json +{"prompt_caching": {"mode": "openai", "retention": "in_memory"}} +``` + +OpenAI mode adds an opaque conversation-and-model-scoped `prompt_cache_key` when +conversation context exists. `retention` is optional (`in_memory` or `24h`) and +must be supported by the destination model/gateway. Native explicit +`prompt_cache_key`, `prompt_cache_retention`, and newer `prompt_cache_options` +take precedence. Routing keys are hints, not cache-hit guarantees; current models +may route caching automatically or use newer native options instead. This mode +supports OpenAI-compatible/OpenRouter OpenAI models; forwarding through gateways +must be verified separately. + +```json +{"prompt_caching": {"mode": "anthropic", "ttl": "5m"}} +``` + +Anthropic mode supports native Claude and OpenRouter Claude. It adds one ephemeral +breakpoint to the last text block in the leading system/developer prefix, without +reordering tools/history or marking user messages. TTL is `5m` (default) or `1h`. +If any native cache-control marker exists, Core preserves it without adding more. +With no eligible prefix, no marker is inserted. Provider minimum prefix lengths, +TTL pricing and eligibility still apply. Explicit `provider_default` adds no fields. +Codex's restricted Responses body does not accept this caching object; its existing +server-managed caching behavior is preserved. Gemini explicit cache resource +creation/lifecycle and general Responses migration are outside this batch. + +References checked on 2026-10-15: + +- https://developers.openai.com/api/docs/guides/prompt-caching +- https://platform.claude.com/docs/en/build-with-claude/prompt-caching +- https://platform.claude.com/docs/en/build-with-claude/extended-thinking +- https://ai.google.dev/gemini-api/docs/generate-content/thinking +- https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + +See [the latency benchmark](LLM_LATENCY_BENCHMARK.md) for measured results and limitations. diff --git a/docs/POSTGRES_CONTRACT_TESTS.md b/docs/POSTGRES_CONTRACT_TESTS.md new file mode 100644 index 000000000..5286da109 --- /dev/null +++ b/docs/POSTGRES_CONTRACT_TESTS.md @@ -0,0 +1,81 @@ +# Disposable PostgreSQL contract tests + +The fast integration suite deliberately runs without an external database. At +the current baseline, 83 cases in three persistence modules require PostgreSQL: + +| Module | PostgreSQL cases | Contract | +| --- | ---: | --- | +| `test_pipeline_admission_postgres.py` | 27 | Real row-lock admission, tenant isolation, rollback, cancellation and commit reconciliation | +| `test_rag_document_identity.py` | 34 | Persisted engine document identity, scoped deletion and migration convergence | +| `test_rag_interrupted_ingestion.py` | 22 | Interrupted/cancelled ingestion, delayed SDK writes, recovery and retained uploads | + +Another 39 PostgreSQL cases are marked slow and deselected by Fast Integration +Tests rather than reported as skipped: + +| Module | PostgreSQL cases | Contract | +| --- | ---: | --- | +| `test_migration_branch_convergence.py` | 7 | Real historical/populated upgrade branches, certification backfill and merge-only downgrade | +| `test_runner_timestamps_postgres.py` | 32 | Non-superuser timestamp/lease/retention contracts against both metadata and published-migration schemas | + +The PostgreSQL Contract Tests job in `.github/workflows/run-tests.yml` provisions +a dedicated `pgvector/pgvector:pg16` service, sets both required URL variables, +and runs all 122 PostgreSQL cases. Admission uses a separate database from RAG, +convergence and timestamp tests: its populated `public` schema must not affect +other fixtures' schema discovery through their `schema,public` search path. +Its JUnit check requires each module's baseline +case count and rejects skips, failures and errors. Results are uploaded even when +the job fails. SQLite variants continue running in Fast Integration Tests. + +## Run locally + +Use a new disposable database. The admission fixture drops and recreates the +entire `public` schema and creates a test runtime role. Never use a development, +staging or production database, or a shared database containing useful data. + +For example, start an isolated container bound only to the local loopback address: + +```bash +docker run --rm -d --name langbot-contract-pg \ + -e POSTGRES_USER=postgres -e POSTGRES_DB=langbot_test \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + -p 127.0.0.1:55432:5432 pgvector/pgvector:pg16 +docker exec langbot-contract-pg pg_isready -U postgres -d langbot_test +``` + +Wait until `pg_isready` reports that the server is accepting connections, then: + +```bash +docker exec langbot-contract-pg psql -U postgres -d postgres \ + -v ON_ERROR_STOP=1 -c 'CREATE DATABASE langbot_admission_test;' +uv sync --dev --frozen +export TEST_POSTGRES_URL=postgresql+asyncpg://postgres@localhost:55432/langbot_test +export LANGBOT_ADMISSION_TEST_URL=postgresql+asyncpg://postgres@localhost:55432/langbot_admission_test +uv run pytest \ + tests/integration/persistence/test_pipeline_admission_postgres.py \ + tests/integration/persistence/test_rag_document_identity.py \ + tests/integration/persistence/test_rag_interrupted_ingestion.py \ + tests/integration/persistence/test_migration_branch_convergence.py \ + tests/integration/persistence/test_runner_timestamps_postgres.py \ + -k postgres -q --tb=short -rs --durations=10 \ + --junitxml=postgres-contract-results.xml +docker stop langbot-contract-pg +``` + +Trust authentication is limited to this throwaway, loopback-bound service. The +admission fixture needs the literal `postgres@` URL to derive a separate +unprivileged runtime-role connection. RAG fixtures create and clean up their own +unique schemas, as do convergence fixtures. Timestamp fixtures create a unique +non-superuser, non-RLS-bypass role and owned schema for each case and remove both +afterward. The admin connection therefore needs role/schema creation privileges. +These settings do not belong in an application configuration. + +## Other skip categories + +- The monitoring PostgreSQL unit regression is separately selected by the + Migrations (PostgreSQL) job in `.github/workflows/test-migrations.yml` +- For plugin identity, compiled CLI and embedded SeekDB prerequisites, see + [the dependency contract setup in PR #2636](https://github.com/langbot-app/LangBot/pull/2636). + +These remaining prerequisites should be reported explicitly rather than treated +as passing tests. The four chat-handler logging tests now initialize the normal +application import graph before importing the handler and no longer skip. diff --git a/docs/architecture/backend-i18n.md b/docs/architecture/backend-i18n.md new file mode 100644 index 000000000..bf5a4ba35 --- /dev/null +++ b/docs/architecture/backend-i18n.md @@ -0,0 +1,68 @@ +# Backend message language + +Workspace settings expose one **Workspace language**, independent of each member's +WebUI language. It selects LangBot-owned API business errors, model error prefixes, +Pipeline default notices and international platform replies. Supported locales are +`en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, `es_ES`, `ru_RU`, `th_TH` and `vi_VN`. +Workspaces without a setting default to English. Existing custom messages and +historical records are not rewritten. + +## Storage and boundaries + +`pkg/i18n/workspace.py` stores `message_language` in existing `WorkspaceMetadata`; +no database migration or instance-wide configuration is needed. Reads and writes +use tenant-scoped units of work. GET/PUT `/api/v1/workspaces/current/language` +require `workspace.view` / `workspace.update`; matching MCP tools are +`get_workspace_message_language` and `set_workspace_message_language`. + +An authenticated Workspace takes precedence over the browser's +`X-LangBot-Language` header. The header supplies presentation language only for +account-only or unauthenticated responses and never selects a tenant. HTTP, MCP, +Pipeline and adapter boundaries set a ContextVar so concurrent Workspaces do not +share a language. Adapter SDK thread callbacks use their owning bot's cached +Workspace language, never the calling request's language. + +The per-application cache holds at most 256 Workspaces for 30 seconds. Local +writes update the cache immediately; other processes refresh at the next async +message/request boundary after expiry. A running operation keeps its selected +language. Synchronous SDK callbacks use the most recently loaded value. + +## Pipeline overrides + +The output `misc` section accepts these optional string settings: + +| Setting | Default purpose | Placeholder | +| --- | --- | --- | +| `failure-hint` | Request/runner execution failure | none | +| `rate-limit-hint` | Rate limit reached | none | +| `content-filter-hint` | Message rejected by content filter | none | +| `content-filter-error-hint` | Content check failed | none | +| `tool-call-hint` | Tool call notification | `{tools}` | +| `runner-not-found-hint` | Configured runner missing | `{runner}` | +| `runner-not-authorized-hint` | Runner authorization failure | none | +| `runner-unavailable-hint` | Runner temporarily unavailable | none | + +Blank values inherit the Workspace translation. Custom text is emitted literally +with only the documented placeholder replaced. The previous shipped +`failure-hint` value `Request failed.` also inherits language; this avoids a data +migration. Existing output switches (including tool-call visibility and exception +handling) retain their behavior. + +## Adding messages + +Use `pkg.i18n.t()` with a stable English template and named parameters. Add the +same key and placeholders to all eight JSON catalogs. The catalog parity test +checks completeness and formatting. `pipeline_notice()` handles configurable +notices. Exact aliases at the API response boundary cover legacy business wording; +do not add substring replacement of arbitrary error text. + +China-only adapters, field validation diagnostics, raw provider/plugin errors, +tracebacks, protocol values and machine-readable status codes are outside this +translation layer. Their values remain useful for diagnostics and interoperability. +Model errors translate LangBot's category/prefix while retaining upstream detail. +Plugin-authored content must use the plugin's own localization. System prompts and +user-authored content are not translated. + +Locale JSON files are included in Python package data. Focused coverage lives in +`tests/unit_tests/test_backend_i18n.py`, the Workspace HTTP integration tests and +`web/tests/e2e/workspace-language.spec.ts`. diff --git a/docs/architecture/cloud-plan-revisions.md b/docs/architecture/cloud-plan-revisions.md new file mode 100644 index 000000000..2cc7d3c37 --- /dev/null +++ b/docs/architecture/cloud-plan-revisions.md @@ -0,0 +1,37 @@ +## Cloud plan comparison + +`EntitlementSnapshot.plan_revision` and `monthly_bonus_credits` are optional +signed display metadata. The Space adapter opts into these fields with +`plan_metadata=2`. `/api/v1/workspaces/current` exposes them as `current_plan`, +alongside the snapshot's effective limits and feature switches. The existing +Workspace permission check and entitlement resolver remain authoritative. +Admission and quotas do not use plan names or display metadata. + +The comparison dialog shows the purchased revision even when the catalog has +no matching plan. It compares resource limits, retention, credits, platform +restrictions and feature switches; identical benefits use one column even if +the revision changed. Missing signed metadata is an explicit unavailable state. +Purchase links for existing paid plans go to Space subscription management, +where permissions, cancellation and revision eligibility are checked again. + +This adds display information to an existing authenticated response, not an +agent operation. There is no new MCP operation or billing mutation in Core. +Roll out the matching Core snapshot model before the updated Space adapter. + +Verification: + +```sh +uv run pytest tests/unit_tests/cloud/test_entitlements.py tests/integration/api/test_workspaces.py -q +cd web +node --test tests/unit/cloud-plans.test.mjs tests/unit/plan-revisions.test.mjs +pnpm build +node tests/revisions/verify.mjs +``` + +The browser runner expects sibling `space` and `core` checkouts and uses isolated +fixtures and mocked catalog responses. It never authenticates to real Space or +initiates a real payment, and shuts down its own servers after the tests. + +Purchased benefits are a card in the existing horizontally scrollable plan card area, immediately before the matching latest plan. There is no separate revision comparison panel, and historical prices are not inferred from the latest catalog. + +Customer presentation uses subscription dates and “Latest quotas for this plan”, never revision numbers. Unrelated plans and current latest subscriptions have no comparison markers. Core reuses its normal Benefits card content without diff rows or highlighting. Internal entitlement feature keys are not rendered as product benefits. The optional signed subscription_started_at is the current subscription period start, returned only for a matching entitlement revision; it is not snapshot issuance time. Requests with plan_metadata=1 retain their previous response shape. diff --git a/docs/llm-latency-2026-10-15.json b/docs/llm-latency-2026-10-15.json new file mode 100644 index 000000000..628e02868 --- /dev/null +++ b/docs/llm-latency-2026-10-15.json @@ -0,0 +1,411 @@ +{ + "model": "claude-haiku-4.5", + "litellm": "1.88.1", + "opencode": "2.0.16", + "canonical_sha256": "7834a66a188e70b4eb1b6234b3a14be4c66b8c72dd8a2c06395fe2505c1c371b", + "canonical_bytes": 31785, + "summary": { + "direct": { + "samples": 6, + "dispatch_to_wire_ms": { + "median": 3.952, + "max": 5.44 + }, + "upstream_ttft_ms": { + "median": 1115.744, + "max": 1158.609 + }, + "ttft_ms": { + "median": 1119.541, + "max": 1164.048 + }, + "total_ms": { + "median": 2199.413, + "max": 2219.7 + }, + "cache_read_tokens": [ + 5987, + 5987, + 5987, + 5987, + 5987, + 5987 + ] + }, + "langbot": { + "samples": 6, + "dispatch_to_wire_ms": { + "median": 6.802, + "max": 10.028 + }, + "upstream_ttft_ms": { + "median": 1079.658, + "max": 1113.394 + }, + "ttft_ms": { + "median": 1089.092, + "max": 1120.475 + }, + "total_ms": { + "median": 2135.935, + "max": 2175.882 + }, + "preparation_ms": { + "median": 0.316, + "max": 0.497 + }, + "cache_read_tokens": [ + 5987, + 5987, + 5987, + 5987, + 5987, + 5987 + ] + }, + "opencode": { + "samples": 6, + "dispatch_to_wire_ms": { + "median": 41.158, + "max": 50.283 + }, + "upstream_ttft_ms": { + "median": 1063.875, + "max": 1158.768 + }, + "ttft_ms": { + "median": 1104.02, + "max": 1201.731 + }, + "total_ms": { + "median": 2148.265, + "max": 2244.956 + }, + "cache_read_tokens": [ + 5987, + 5987, + 5987, + 5987, + 5987, + 5987 + ] + } + }, + "records": [ + { + "client": "direct", + "round": -1, + "warmup": true, + "dispatch_to_wire_ms": 3.500899998471141, + "upstream_ttft_ms": 2094.1667999722995, + "ttft_ms": 2097.6676999707706, + "total_ms": 3145.2215999597684, + "client_socket_reused": false, + "consumer_first_content_ms": 2098.3635999727994, + "consumer_total_ms": 3146.0422999807633, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": -1, + "warmup": true, + "dispatch_to_wire_ms": 1452.2201999789104, + "upstream_ttft_ms": 1122.500299999956, + "ttft_ms": 2574.7204999788664, + "total_ms": 3640.2128000045195, + "preparation_ms": 0.373, + "transport_setup_ms": 441.243, + "client_socket_reused": false, + "consumer_first_content_ms": 2576.894700003322, + "consumer_total_ms": 3646.833000006154, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": -1, + "warmup": true, + "dispatch_to_wire_ms": 846.3009999832138, + "upstream_ttft_ms": 1082.7391999773681, + "ttft_ms": 1929.040199960582, + "total_ms": 3569.3477999884635, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 0, + "warmup": false, + "dispatch_to_wire_ms": 3.5812999703921378, + "upstream_ttft_ms": 1012.8218000172637, + "ttft_ms": 1016.4030999876559, + "total_ms": 2067.0370999723673, + "client_socket_reused": false, + "consumer_first_content_ms": 1017.0787000097334, + "consumer_total_ms": 2067.508200008888, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 0, + "warmup": false, + "dispatch_to_wire_ms": 6.439700024202466, + "upstream_ttft_ms": 1067.0202999608591, + "ttft_ms": 1073.4599999850616, + "total_ms": 2151.4558999915607, + "preparation_ms": 0.281, + "transport_setup_ms": 0.017, + "client_socket_reused": true, + "consumer_first_content_ms": 1074.6371999848634, + "consumer_total_ms": 2156.441500002984, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 0, + "warmup": false, + "dispatch_to_wire_ms": 50.28290004702285, + "upstream_ttft_ms": 1151.4483999926597, + "ttft_ms": 1201.7313000396825, + "total_ms": 2244.956100010313, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 1, + "warmup": false, + "dispatch_to_wire_ms": 3.269899985753, + "upstream_ttft_ms": 1149.7626000200398, + "ttft_ms": 1153.0325000057928, + "total_ms": 2198.293499997817, + "client_socket_reused": true, + "consumer_first_content_ms": 1153.3279999857768, + "consumer_total_ms": 2198.7193999812007, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 1, + "warmup": false, + "dispatch_to_wire_ms": 40.65959999570623, + "upstream_ttft_ms": 1158.767800021451, + "ttft_ms": 1199.4274000171572, + "total_ms": 2242.273700016085, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 1, + "warmup": false, + "dispatch_to_wire_ms": 6.523299962282181, + "upstream_ttft_ms": 1070.9955000202172, + "ttft_ms": 1077.5187999824993, + "total_ms": 2109.3437999952585, + "preparation_ms": 0.497, + "transport_setup_ms": 0.015, + "client_socket_reused": true, + "consumer_first_content_ms": 1081.5072999685071, + "consumer_total_ms": 2113.767499977257, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 2, + "warmup": false, + "dispatch_to_wire_ms": 7.08060001488775, + "upstream_ttft_ms": 1113.394399988465, + "ttft_ms": 1120.4750000033528, + "total_ms": 2175.882300012745, + "preparation_ms": 0.221, + "transport_setup_ms": 0.009, + "client_socket_reused": true, + "consumer_first_content_ms": 1122.9100999771617, + "consumer_total_ms": 2187.183100031689, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 2, + "warmup": false, + "dispatch_to_wire_ms": 5.439500033389777, + "upstream_ttft_ms": 1158.6087999748997, + "ttft_ms": 1164.0483000082895, + "total_ms": 2219.7004000190645, + "client_socket_reused": false, + "consumer_first_content_ms": 1164.5996000152081, + "consumer_total_ms": 2220.197000016924, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 2, + "warmup": false, + "dispatch_to_wire_ms": 41.65710002416745, + "upstream_ttft_ms": 1063.7803999707103, + "ttft_ms": 1105.4374999948777, + "total_ms": 2145.6969000282697, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 3, + "warmup": false, + "dispatch_to_wire_ms": 10.027599986642599, + "upstream_ttft_ms": 1080.2179000456817, + "ttft_ms": 1090.2455000323243, + "total_ms": 2105.4020000156015, + "preparation_ms": 0.406, + "transport_setup_ms": 0.012, + "client_socket_reused": true, + "consumer_first_content_ms": 1092.152299999725, + "consumer_total_ms": 2109.886699996423, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 3, + "warmup": false, + "dispatch_to_wire_ms": 38.63289998844266, + "upstream_ttft_ms": 1063.9694000128657, + "ttft_ms": 1102.6023000013083, + "total_ms": 2139.4490000093356, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 3, + "warmup": false, + "dispatch_to_wire_ms": 4.3889000080525875, + "upstream_ttft_ms": 1157.878800004255, + "ttft_ms": 1162.2677000123076, + "total_ms": 2214.4845000002533, + "client_socket_reused": false, + "consumer_first_content_ms": 1162.9892000346445, + "consumer_total_ms": 2214.923900028225, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 4, + "warmup": false, + "dispatch_to_wire_ms": 34.960800025146455, + "upstream_ttft_ms": 1052.52319999272, + "ttft_ms": 1087.4840000178665, + "total_ms": 2137.3740999842994, + "client_socket_reused": true, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 4, + "warmup": false, + "dispatch_to_wire_ms": 2.0186000037938356, + "upstream_ttft_ms": 987.3963000136428, + "ttft_ms": 989.4149000174366, + "total_ms": 2200.532699993346, + "client_socket_reused": true, + "consumer_first_content_ms": 989.9038000148721, + "consumer_total_ms": 2201.0204999824055, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 4, + "warmup": false, + "dispatch_to_wire_ms": 6.363800028339028, + "upstream_ttft_ms": 1112.4532999820076, + "ttft_ms": 1118.8171000103466, + "total_ms": 2150.362300046254, + "preparation_ms": 0.325, + "transport_setup_ms": 0.011, + "client_socket_reused": true, + "consumer_first_content_ms": 1120.592100021895, + "consumer_total_ms": 2154.264000011608, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "opencode", + "round": 5, + "warmup": false, + "dispatch_to_wire_ms": 41.722599999047816, + "upstream_ttft_ms": 1059.4015000388026, + "ttft_ms": 1101.1241000378504, + "total_ms": 2150.8331000222825, + "client_socket_reused": false, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "langbot", + "round": 5, + "warmup": false, + "dispatch_to_wire_ms": 8.840300026349723, + "upstream_ttft_ms": 1079.098400019575, + "ttft_ms": 1087.9387000459246, + "total_ms": 2121.5078000095673, + "preparation_ms": 0.307, + "transport_setup_ms": 0.021, + "client_socket_reused": true, + "consumer_first_content_ms": 1091.6245000553317, + "consumer_total_ms": 2126.561200013384, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + }, + { + "client": "direct", + "round": 5, + "warmup": false, + "dispatch_to_wire_ms": 4.323699977248907, + "upstream_ttft_ms": 1081.7263000062667, + "ttft_ms": 1086.0499999835156, + "total_ms": 2126.9287999602966, + "client_socket_reused": false, + "consumer_first_content_ms": 1086.7821999709122, + "consumer_total_ms": 2127.6911000022665, + "input_tokens": 5990, + "output_tokens": 109, + "cache_read_tokens": 5987 + } + ] +} diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md index 482a6bbeb..5b2fa7d38 100644 --- a/docs/pipeline-migration-config-map.zh-CN.md +++ b/docs/pipeline-migration-config-map.zh-CN.md @@ -18,7 +18,7 @@ - Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。 - Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。 - Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。 -- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。 +- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突时固定采用当前规范字段的值并给出提示,不靠字段出现顺序决定。 - N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。 - `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。 - 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。 @@ -28,19 +28,35 @@ 以下是迁移器要求的插件最低版本,不是认证版本清单。已发布的专属运行版本不等于已认证可跨工作区共享;旧版本即使名称相同,也不能作为当前 Cloud 迁移能力的证明。认证须由精确制品的有效签名及 `shared-runtime-v1`、`stateless-v1` 声明证明。 -- `local-agent` → `LocalAgent` **0.1.10**。 -- `dify-service-api` → `DifyAgent` **0.1.10**。 -- `coze-api` → `CozeAgent` **0.1.10**。 -- `dashscope-app-api` → `DashScopeAgent` **0.1.10**。 -- `n8n-service-api` → `N8nAgent` **0.1.10**。 -- `langflow-api` → `LangflowAgent` **0.1.10**。 -- `deerflow-api` → `DeerFlowAgent` **0.1.10**。 -- `tbox-app-api` → `TboxAgent` **0.1.8**。 -- `weknora-api` → `WeKnoraAgent` **0.1.10**。 +- `local-agent` → `LocalAgent` **0.2.1**。 +- `dify-service-api` → `DifyAgent` **0.2.3**。 +- `coze-api` → `CozeAgent` **0.2.0**。 +- `dashscope-app-api` → `DashScopeAgent` **0.2.2**。 +- `n8n-service-api` → `N8nAgent` **0.2.0**。 +- `langflow-api` → `LangflowAgent` **0.2.2**。 +- `deerflow-api` → `DeerFlowAgent` **0.2.1**。 +- `tbox-app-api` → `TboxAgent` **0.2.1**。 +- `weknora-api` → `WeKnoraAgent` **0.2.1**。 + +版本号是经过审阅的最低版本:安装时优先安装该版本;若扩展市场已下架该版本则回退安装最新版。已安装的更高版本不会被降级,迁移执行前仍以运行时 Runner schema 校验迁移结果。 + +## 宽松迁移规则(planner v5) + +迁移器尽量自动修复,只在无法安全运行时阻断: + +- 仍然阻断:配置不是对象/不是合法 JSON;必填凭据或端点缺失/为空(api-key、base-url、webhook-url、app-id、bot-id、flow-id、LocalAgent 缺少主模型);n8n header 名称非法或 basic 认证凭据不是 latin-1;Coze api-base 含凭据、查询串或片段。 +- 自动修复并提示: + - 字符串形式的布尔/数字(如 `"true"`、`"30"`)按字段类型转换;无法转换时使用默认值(`migration.value_coerced` / `migration.invalid_value_defaulted`)。 + - 缺失字段按 4.10 界面默认值补齐(`migration.missing_default_filled`)。 + - 未知字段与残留的其他 Runner 配置段从活动配置移除,原值保存在迁移备份(`migration.unknown_field_dropped` / `migration.unknown_section_archived`)。 + - 别名冲突固定取规范字段(`migration.alias_conflict_resolved`);同时存在 `runner.id` 与旧 `runner.runner` 时采用可用的一项(`migration.mixed_runner_selection`)。 + - 扩展设置非法时忽略;仅启用部分插件时自动加入目标 Runner 插件(`extensions.runner_added`)。 +- 旧版自定义 Runner(非内置 9 类)标记为 `unsupported_runner`:不迁移、不算失败,需改写为插件 Runner 后再绑定,参见 https://langbot.app/docs/zh/plugin/dev/tutor 。 +- 诊断只包含代码和字段路径,不包含任何值或凭据。 ## 逐字段完整清单 -共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。 +共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时从活动配置移除、原值保留在迁移备份中并给出提示。 目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。 diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md index 4a390ebf1..64adf4627 100644 --- a/docs/review/box-architecture.md +++ b/docs/review/box-architecture.md @@ -24,6 +24,7 @@ │ │ │ (shared 容器, 多 process) │ │ │ │ │ │ │ ├──> SkillToolLoader (activate 工具) │ +│ │ │ └─ build_execution_mounts() │ │ │ │ │ │ │ ├──> SkillAuthoringToolLoader │ │ │ │ │ @@ -35,7 +36,7 @@ │ ├─ Workspace quota 检查 │ │ ├─ 输出截断 (head+tail) │ │ ├─ Host scope 哈希 (resolve_box_session_id) │ -│ ├─ 技能挂载组装 (build_skill_extra_mounts) │ +│ ├─ 通用只读挂载接收 (read_only_mounts) │ │ ├─ 重连循环 (_reconnect_loop, 指数退避) │ │ └─ BoxRuntimeConnector │ │ ├─ 心跳 loop (20s ping) │ @@ -43,7 +44,7 @@ │ │ Action RPC (stdio 或 WebSocket) │ │ │ │ SkillManager (skill_mgr) │ -│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │ +│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │ └──────────────────────────────────────────────────────────────────┘ │ ▼ @@ -61,10 +62,8 @@ │ 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) │ +│ Generic mount admission │ +│ └─ allow-list + read-only + normalized target validation │ │ │ │ aiohttp 单端口服务 (默认 :5410): │ │ /rpc/ws — Action RPC │ @@ -89,6 +88,7 @@ - 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process - Runner 无权指定 session scope。SDK/Python `call_tool` 与 scoped MCP bridge 都发出同一个 `PluginToRuntimeAction.CALL_TOOL`,最终由 Host 的 ToolManager 执行,并使用当前 run 保存的同一个 execution Query - Box 内托管的 stdio MCP server 使用独立的长期 `mcp-shared` session;它不是 Runner 本次事件的 sandbox session(详见 [box-session-scope.md](./box-session-scope.md)) +- Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 只共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md)) --- @@ -96,7 +96,7 @@ ### 2.1 BoxService (`pkg/box/service.py`, 722 行) -应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Host scope 哈希: +应用层门面,协调 Profile、安全校验、配额、连接、Host scope 哈希与 Core 生成的只读挂载: 主要公开方法(按定义顺序): @@ -108,7 +108,7 @@ BoxService ├─ available (property) 连接状态 │ ├─ resolve_box_session_id(query) 哈希 Host 私有 scope,生成固定长度 session_id - ├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 + ├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载 │ ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 │ ├─ _apply_profile / build_spec @@ -125,12 +125,6 @@ BoxService ├─ 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 系统提示 @@ -142,7 +136,7 @@ BoxService **Session 所有权**: `resolve_box_session_id(query)` 只接受 Host 已确定的私有 scope 或 Query launcher/session identity,并输出 `lb-box-` + 64 位小写 SHA-256 十六进制摘要(固定 71 个 ASCII 字符)。哈希输入是 canonical JSON,包含 instance、workspace、bot、platform adapter、target type/id 与 thread;原始用户、群组、conversation 或 event id 不会出现在 Box session id 中。相同 Host scope 稳定复用,不同 target/thread/workspace/bot/adapter/instance 相互隔离;缺少可用 identity 时 fail closed。Pipeline、Agent 或 Runner 配置都不能覆盖该规则。 -**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 +**Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`,把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径;BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill。 ### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) @@ -176,11 +170,10 @@ BoxService ``` SkillManager ├─ initialize() 调用 reload_skills() - ├─ reload_skills() 先从 Box runtime list_skills(), - │ 不可用则回落 data/skills/ 扫描 - ├─ refresh_skill_from_disk() 单 skill 重新加载 + ├─ reload_skills() 从 Core SkillRepository 加载 + ├─ refresh_skill_from_disk() 检查单 skill 的缓存状态 ├─ get_skill_by_name(name) - └─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径 + └─ build_skill_aware_prompt_addition() 生成渐进披露索引 ``` skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。 @@ -300,7 +293,7 @@ start_managed_process(session, spec): 单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口): -1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等 +1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 `INIT`、exec、session、managed-process 与状态等通用 action 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。 @@ -309,7 +302,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta `ActionRPCBoxClient` 封装 `Handler.call_action()` 调用: -- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown) +- 方法对应 exec / session / managed-process / status / shutdown 等通用 RPC action,不暴露 Skill CRUD - 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型 - `execute()` timeout = 300s,其他默认 15s - `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用 @@ -348,23 +341,29 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta `BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。 -### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行) +### 3.7 SkillStore (`langbot_plugin.skill_store`) -新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime: +Skill 包存储最初位于 Box Runtime;issue #2410 将通用实现抽到 Plugin SDK 顶层,由 Core 独占存储和 revision 语义: ``` -BoxSkillStore +SkillStore ├─ 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) + ├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源 + ├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源 + ├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口 ├─ 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/`。 +GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件位于独立的 `skills.root`,执行时由 Core 组装成通用只读 `BoxMountSpec` 并挂载到 `/workspace/.skills/`。Box 的模型、客户端和 Runtime 不包含 `skill_name`、Skill CRUD、revision 或 `SKILL.md` 语义。Core 与 Box Runtime SDK 按同一发布单元同步升级;Box 不保留旧 Skill RPC,也会拒绝旧的 Skill-aware payload 字段。 + +仍保留的兼容仅用于已有数据的在线升级,不用于混版本协议: + +1. Core 暂时读取 `box.local.skills_root`,兼容尚未生成 `skills.root` 的持久化配置。 +2. 新安装默认暂时沿用历史 `./data/box/skills`,避免升级时搬迁已安装 Skill;下一大版本切换到 `./data/skills`。 ### 3.8 Security (`box/security.py`, 52 行) @@ -472,7 +471,7 @@ BuildAppStage.run(ap) ├─ ap.tool_mgr = tool_mgr │ ├─ ... (platform, pipeline) ... - ├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表) + ├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表) └─ ... (RAG, HTTP, plugins) ... ``` @@ -489,7 +488,7 @@ except Exception as e: logger.warning(f"Box runtime unavailable: {e}") ``` -**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 +**静默降级**: Box 初始化失败不会阻止应用启动。6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM;`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。 ### 5.3 销毁流程 @@ -513,13 +512,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的 ### config.yaml (重构后) ```yaml +skills: + root: './data/box/skills' # Core-owned;Box 关闭时仍可管理/读取 + box: enabled: true # 整个 Box 子系统的总开关。设为 false 时: # - 不连接远程 Box runtime,不 fork 本地 stdio 子进程 # - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM - # - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝 + # - Agent 从 sandbox 注册 skill 的能力不可用 # - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响) - # - skill 列表/读取保持只读可用 + # - skill 管理、激活和只读资源保持可用 # BOX__ENABLED 环境变量可覆盖(统一约定) backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' # 由 box.backend / BOX__BACKEND 选择后端 @@ -531,7 +533,6 @@ box: image: '' # 覆盖 profile 默认 image host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径 default_workspace: '' # 默认 '/default' - skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root) allowed_mount_roots: # 默认 [''] - './data/box' - '/tmp' @@ -570,15 +571,18 @@ volumes: | 消费方 | Box 可用 | Box 不可用(disabled 或 failed) | |---|---|---| | native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** | -| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** | +| `activate` / Skill resource 工具 | 暴露给 LLM | 暴露给 LLM | +| `register_skill` 工具 | 暴露给 LLM | **不暴露**;直接调用返回 `sandbox_unavailable` | | 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 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | +| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository | +| Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository | +| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 | | 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | -> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。 +> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount。 + +自定义 `skills.root` 若位于 Box 的 `allowed_mount_roots` 之外,Skill 仍可管理和读取,但脚本执行的只读挂载会被拒绝。执行这些 Skill 时,需将该路径加入允许的挂载根;分离部署还需让 Core 和 Box Runtime 在相同路径看见这份目录。 ### Session scope diff --git a/docs/review/box-issues.md b/docs/review/box-issues.md index 15650c7c5..7c3922499 100644 --- a/docs/review/box-issues.md +++ b/docs/review/box-issues.md @@ -52,8 +52,8 @@ ### 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 两侧都纳入挂载校验。 +- **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单。 +- **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。 ### S6. 容器加固缺失 — Med diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md index 11c53a8d2..99b370d7e 100644 --- a/docs/review/box-test-coverage.md +++ b/docs/review/box-test-coverage.md @@ -53,6 +53,7 @@ | BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | | BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | | BoxService Host-owned session | 优秀 | 覆盖 `lb-box-` 固定格式、原始 identity 不泄露、同 scope 稳定、不同 conversation/scope 隔离、缺 identity fail closed | +| BoxService mount contract | 良好 | 通用 `read_only_mounts`;Skill mount 由 Core loader 组装 | | RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | | BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | | BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | diff --git a/docs/review/telegram-4.11-acceptance.md b/docs/review/telegram-4.11-acceptance.md new file mode 100644 index 000000000..987ae0ad5 --- /dev/null +++ b/docs/review/telegram-4.11-acceptance.md @@ -0,0 +1,72 @@ +# Telegram 4.11 adapter acceptance + +Validated on 2026-10-11 using the Telegram Web client and LangBot WebUI through computer use. Bot registration, bot configuration, processor bindings, group membership changes, and test messages were performed through the UI. No direct Bot API calls or database mutations were used to simulate acceptance traffic. + +## Environment + +- Core 4.11.0rc2, SDK 0.7.11rc2 on their main branches. +- OSS development mode, standalone Plugin Runtime and standalone Docker-backed Box Runtime. +- Dedicated new Telegram bot and dedicated test group; no unrelated conversations used. +- Pipeline and Agent: LocalAgent with gemini-3-flash-preview. Plugin processors: RunnerDemo community and observer. + +## Real message coverage + +| Path | Verification | +| --- | --- | +| Pipeline private text | TG-P01 received an actual reply. | +| Pipeline group text and streaming | TG-G04 and TG-STREAM05; observed partial output growing from 12 to 22 to 60 lines, ending with STREAM-END. | +| Pipeline private streaming | TG-PRIVATE-STREAM10; observed 8 partial lines progressing to 80 complete lines and STREAM-DONE. | +| Pipeline incoming image | TG-IMG01 correctly identified the red left half and blue right half of a synthetic image. | +| Pipeline incoming file | TG-FILE01 read marker TG-FILE-7319 and calculated 17 + 25 + 38 = 80. | +| Pipeline outgoing media | TG-FILE07 delivered a real green PNG and verified.txt as Telegram attachments. | +| Invalid image fallback | TG-FALLBACK08 delivered a-check.txt once and an intentionally invalid nine-byte PNG as a document once, without replaying the preceding file. | +| Agent private and group | TG-A01/A02/A03 and TG-COEXIST01 exercised event_reply and real delivery. Historical conversation wording influenced early replies; explicit current instructions were used for subsequent media tests. | +| Agent group image input | TG-AGENT-GIMG02 correctly identified left red/right blue. | +| Agent group file input/output | TG-AGENT-GFILE01 read the marker and sum and delivered result.txt (27 bytes); run completed in 10.72 seconds. Downloaded the Telegram attachment and verified its contents: Code: TG-FILE-7319 and Sum: 80. | +| Agent group image output | TG-AGENT-GOUT01 generated a real 64x64 PNG using Box, delivered it as a photo, and completed in 12.96 seconds. | +| Plugin private/group text | TG-PLUGIN-P02 and TG-REACTION-02 echo replies were delivered with completed runs after fixing MessageResult. | +| Plugin attachment events | Community processor independently recorded Image and File components, with completed runs. It intentionally sends no reply without its command prefix. | +| Independent subscriptions | TG-COEXIST01 produced both the plugin reply and Agent reply. | + +Agent event_reply is a complete send, not token streaming. RunnerDemo community provides progress logs but no streaming or generated-media command; those outputs were tested through Pipeline and Agent instead. This is not a claim that every processor implements every output mode. + +## Real event coverage + +| Event | UI action and observed result | +| --- | --- | +| message.received | Sent private/group text and attachments. | +| message.edited | Edited TG-EDIT01; Agent reported the actual edited event. | +| message.reaction | Added then removed thumbs-up; observer payloads retained the emoji with is_add true/false. | +| group.member_joined | Added a dedicated existing test bot; observer recorded the event and community sent a welcome. | +| group.member_left | Removed that test bot; event had is_kicked true and community sent a departure notice. | +| group.member_banned | Restricted that test bot's sending permission; observer recorded the restriction event. | +| bot.invited_to_group | Re-added the QA bot; observer completed at 10:04:12. | +| bot.removed_from_group | Removed the QA bot while restricted; observer completed at 10:03:55. | +| bot.muted | Disabled sending permission; indefinite duration was null after the fix. | +| bot.unmuted | Restored sending while another restriction remained; observer completed at 10:02:29. | +| platform.specific | Changed administrator status; observer recorded my_chat_member_updated. | + +This covers each declared event type, not every possible Telegram platform-specific subtype. Administrator-only updates were tested with a minimal administrator role in the dedicated group. Replying while muted/removed was not treated as an expected successful action. + +## Fixes driven by acceptance + +1. Normalize numeric conversation IDs before building the Agent protocol context. +2. Return MessageResult from Telegram send/reply so successful delivery does not subsequently fail plugin action decoding. +3. Deliver terminal stream attachments and remove an empty Thinking placeholder for attachment-only completions. +4. Fall back to document delivery for known Telegram photo encoding/dimension rejections, preserving earlier deliveries. +5. Register MessageReactionHandler with the correct keyword argument instead of accidentally filtering by chat ID. +6. Preserve the removed emoji and distinguish additions from removals. +7. Interpret Telegram's Unix-epoch restriction expiry as indefinite; calculate finite duration from event time. +8. Recognize partial unmute and removal from a restricted state. + +## Automated and documentation validation + +- 134 focused tests passed across Telegram legacy/new adapters, native stream completion, Omni parity, and Agent event-first protocol. +- Ruff and git diff whitespace checks passed. +- Chinese, English, and Japanese Telegram guides updated with processor selection, privacy mode, permissions, streaming/media boundaries, event semantics, and troubleshooting. +- Sanitized real group screenshot included in all three guides; rendered image verified in the local built documentation. +- Documentation tests and production static build passed (38 Node tests; 19 Python tests with one skip; 31 static-build tests). + +After synchronizing the documentation main branch, a stale DingTalk screenshot assertion in the migration test was updated to match the current guide; the full documentation suite passed again. + +Provider-specific errors observed with an earlier model were separated from adapter failures; successful acceptance uses the model stated above. This validation does not assert compatibility with every model provider, Telegram update subtype, or plugin implementation. diff --git a/packaging/fnos/manifest b/packaging/fnos/manifest index a05b5be1a..58d657454 100644 --- a/packaging/fnos/manifest +++ b/packaging/fnos/manifest @@ -1,5 +1,5 @@ appname=langbot -version=4.10.11 +version=4.11.0 display_name=LangBot desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。 platform=all diff --git a/pyproject.toml b/pyproject.toml index d74467161..d0b2f62d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot" -version = "4.11.0-beta.6" +version = "4.11.0" description = "Production-grade platform for building agentic IM bots" readme = "README.md" license-files = ["LICENSE"] @@ -71,7 +71,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.7.10", + "langbot-plugin==0.7.13", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", @@ -129,7 +129,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] -package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } +package-data = { "langbot" = ["templates/**", "pkg/i18n/locales/*.json", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "pkg/platform/adapters/**", "web/dist/**", "pkg/persistence/alembic/**"] } [dependency-groups] dev = [ diff --git a/pytest.ini b/pytest.ini index d86cbba9f..8f543757a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -28,6 +28,13 @@ addopts = # Markers markers = + contract(name): stable behavior obligation for required suites + postgres: required postgres execution suite + local_agent: required local_agent execution suite + security: required security execution suite + compiled_cli: required compiled_cli execution suite + plugin_identity: required plugin_identity execution suite + seekdb: required seekdb execution suite asyncio: mark test as async unit: mark test as unit test integration: mark test as integration test diff --git a/scripts/llm_latency_probe.py b/scripts/llm_latency_probe.py new file mode 100644 index 000000000..8696f72da --- /dev/null +++ b/scripts/llm_latency_probe.py @@ -0,0 +1,418 @@ +"""Compare a direct stream, Core requester and isolated OpenCode 2.x server. + +All three clients use a local measurement relay. The relay sends the same +synthetic request bytes and headers upstream, deliberately removing agent prompt +differences. This measures the request layer, not a production bot pipeline/UI. +Credentials are read from an environment variable or an untracked local file. +""" + +import argparse +import asyncio +import contextlib +import hashlib +import itertools +import json +import os +from pathlib import Path +import socket +import statistics +import secrets +import subprocess +import tempfile +import time +from types import SimpleNamespace + +import httpx +from aiohttp import web + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +class SSEMeter: + """Incrementally parse SSE without assuming network chunk boundaries.""" + + def __init__(self): + self.buffer = b'' + self.first_event = None + self.first_content = None + self.usage = None + self.content_chars = 0 + self.done = False + + def feed(self, data, now): + self.buffer += data + while b'\n' in self.buffer: + line, self.buffer = self.buffer.split(b'\n', 1) + if not line.startswith(b'data:'): + continue + raw = line[5:].strip() + if raw == b'[DONE]': + self.done = True + continue + item = json.loads(raw) + if self.first_event is None: + self.first_event = now + if item.get('usage'): + self.usage = item['usage'] + for choice in item.get('choices', []): + content = (choice.get('delta') or {}).get('content') + if content: + if self.first_content is None: + self.first_content = now + self.content_chars += len(content) + + +def cache_tokens(usage): + if not isinstance(usage, dict): + return None + detail = usage.get('prompt_tokens_details') or usage.get('input_tokens_details') or {} + return detail.get('cached_tokens', usage.get('cache_read_input_tokens')) + + +def summary(records): + result = {} + for name in sorted({row['client'] for row in records}): + rows = [r for r in records if r['client'] == name and not r.get('warmup')] + stats = {'samples': len(rows)} + for key in ('dispatch_to_wire_ms', 'upstream_ttft_ms', 'ttft_ms', 'total_ms', 'preparation_ms'): + values = [r[key] for r in rows if r.get(key) is not None] + if values: + stats[key] = {'median': round(statistics.median(values), 3), 'max': round(max(values), 3)} + stats['cache_read_tokens'] = [cache_tokens(r.get('usage')) for r in rows] + result[name] = stats + return result + + +def free_port(): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + + +async def run(options): + # Import/initialization cost is reported separately from resident calls. + started = time.perf_counter() + from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester + from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call + from langbot_plugin.api.entities.builtin.provider.message import Message + import importlib.metadata + + import_ms = (time.perf_counter() - started) * 1000 + credentials = json.loads(Path(options.credentials_file).read_text()) if options.credentials_file else {} + base_url = options.base_url or credentials.get('base_url') + key = os.environ.get(options.api_key_env) or credentials.get('api_key') + if not options.mock and not (base_url and key): + raise ValueError('A base URL and API key are required for live mode') + prefix = 'You are a concise technical assistant. Follow only the final question.\n' + if options.long_prefix: + prefix += ''.join( + f'Reference entry {i}: HTTP connection pooling reuses established transports. ' + 'Keep authentication and timeouts request scoped. Measure first content separately from reasoning.\n' + for i in range(180) + ) + canonical = { + 'model': options.model, + 'stream': True, + 'stream_options': {'include_usage': True}, + 'temperature': 0, + 'max_tokens': 256, + 'messages': [ + { + 'role': 'system', + 'content': [ + { + 'type': 'text', + 'text': prefix, + **({'cache_control': {'type': 'ephemeral'}} if options.cache_control else {}), + } + ], + }, + {'role': 'user', 'content': 'Explain HTTP connection pooling in exactly five short numbered sentences.'}, + ], + } + payload = json.dumps(canonical, separators=(',', ':')).encode() + records = [] + active = None + upstream = httpx.AsyncClient(timeout=90) + relay_sockets = {} + + async def relay(request): + nonlocal active + if active is None or active.get('wire_started') is not None: + return web.json_response({'error': 'Unexpected extra request'}, status=409) + row = active + finished = row['relay_finished'] + row['wire_started'] = time.perf_counter() + incoming = await request.json() + row['received_body_hash'] = digest(incoming) + row['received_fields'] = sorted(incoming) + row['received_message_chars'] = len(json.dumps(incoming.get('messages', []))) + row['upstream_body_hash'] = digest(canonical) + row['dispatch_to_wire_ms'] = (row['wire_started'] - row['start']) * 1000 + connection = id(request.transport) + row['client_socket_reused'] = connection in relay_sockets + relay_sockets[connection] = request.transport + meter = SSEMeter() + response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'}) + try: + if options.mock: + await response.prepare(request) + await asyncio.sleep(0.05) + for i in range(20): + event = { + 'id': 'synthetic', + 'object': 'chat.completion.chunk', + 'created': 1, + 'model': options.model, + 'choices': [{'index': 0, 'delta': {'content': 'word '}, 'finish_reason': None}], + } + data = ('data: ' + json.dumps(event) + '\n\n').encode() + meter.feed(data, time.perf_counter()) + await response.write(data) + await asyncio.sleep(0.03) + event['choices'] = [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}] + event['usage'] = {'prompt_tokens': 50, 'completion_tokens': 20, 'total_tokens': 70} + data = ('data: ' + json.dumps(event) + '\n\ndata: [DONE]\n\n').encode() + meter.feed(data, time.perf_counter()) + await response.write(data) + else: + async with upstream.stream( + 'POST', + base_url.rstrip('/') + '/chat/completions', + content=payload, + headers={'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'}, + ) as remote: + row['status'] = remote.status_code + if remote.status_code != 200: + await remote.aread() + # Do not log upstream response bodies, headers or credentials. + return web.json_response( + {'error': f'Upstream HTTP {remote.status_code}'}, status=remote.status_code + ) + row['upstream_headers_ms'] = (time.perf_counter() - row['wire_started']) * 1000 + await response.prepare(request) + async for chunk in remote.aiter_bytes(): + meter.feed(chunk, time.perf_counter()) + try: + await response.write(chunk) + except ConnectionResetError: + row['client_closed_before_http_eof'] = True + row['upstream_ttft_ms'] = ( + (meter.first_content - row['wire_started']) * 1000 if meter.first_content is not None else None + ) + row['ttft_ms'] = (meter.first_content - row['start']) * 1000 if meter.first_content is not None else None + row['usage'] = meter.usage + row['content_chars'] = meter.content_chars + row['upstream_complete_ms'] = (time.perf_counter() - row['wire_started']) * 1000 + with contextlib.suppress(ConnectionResetError): + await response.write_eof() + return response + finally: + finished.set() + + app = web.Application() + app.router.add_post('/v1/chat/completions', relay) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + local = f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/v1' + adapter = LiteLLMRequester( + SimpleNamespace(), + {'custom_llm_provider': 'openai', 'base_url': local, 'requester_name': 'new-api-chat-completions'}, + ) + await adapter.initialize() + if options.baseline_no_drain: + adapter._http_pool.drain_sse = False + model = SimpleNamespace( + model_entity=SimpleNamespace(name=options.model, extra_args={}, reasoning_config=None, abilities=[]), + reasoning_config_override=None, + provider=SimpleNamespace( + token_mgr=SimpleNamespace(get_token=lambda: 'local-benchmark'), + provider_entity=SimpleNamespace(requester='new-api-chat-completions'), + ), + ) + messages = [Message(role=x['role'], content=x['content']) for x in canonical['messages']] + process = None + with tempfile.TemporaryDirectory(prefix='langbot-llm-benchmark-') as directory: + root = Path(directory) + config = { + 'model': f'bench/{options.model}', + 'small_model': f'bench/{options.model}', + 'autoupdate': False, + 'provider': { + 'bench': { + 'npm': '@ai-sdk/openai-compatible', + 'name': 'Benchmark', + 'options': {'baseURL': local, 'apiKey': 'local-benchmark'}, + 'models': {options.model: {'name': 'Benchmark', 'limit': {'context': 200000, 'output': 256}}}, + } + }, + 'agent': {'bench': {'mode': 'primary', 'prompt': prefix, 'tools': {'*': False}, 'steps': 2}}, + 'permission': {'*': 'deny'}, + } + config_path = root / 'opencode.json' + config_path.write_text(json.dumps(config)) + env = os.environ.copy() + server_password = secrets.token_urlsafe(24) + env.update( + { + 'OPENCODE_CONFIG': str(config_path), + 'XDG_CONFIG_HOME': str(root / 'config'), + 'XDG_DATA_HOME': str(root / 'data'), + 'XDG_CACHE_HOME': str(root / 'cache'), + 'NO_PROXY': os.environ.get('NO_PROXY', ''), + 'OPENCODE_SERVER_PASSWORD': server_password, + } + ) + port = free_port() + server = f'http://127.0.0.1:{port}' + log = (root / 'server.log').open('w') + try: + start_server = time.perf_counter() + process = await asyncio.create_subprocess_exec( + options.opencode, + 'serve', + '--hostname', + '127.0.0.1', + '--port', + str(port), + cwd=root, + env=env, + stdout=log, + stderr=log, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0, + ) + async with httpx.AsyncClient(trust_env=False, timeout=100, auth=('opencode', server_password)) as client: + for _ in range(120): + try: + response = await client.get(server + '/api/info') + if response.is_success: + break + except httpx.ConnectError: + pass + await asyncio.sleep(0.25) + else: + raise RuntimeError('Isolated OpenCode server did not start') + startup_ms = (time.perf_counter() - start_server) * 1000 + orders = list(itertools.permutations(('direct', 'langbot', 'opencode'))) + for round_index in range(-1, options.rounds): + for name in orders[max(0, round_index) % len(orders)]: + session_id = None + if name == 'opencode': + response = await client.post( + server + '/api/session', + json={ + 'title': 'LLM benchmark', + 'agent': 'bench', + 'model': {'id': options.model, 'providerID': 'bench'}, + 'location': {'directory': str(root)}, + }, + ) + response.raise_for_status() + session_id = response.json()['data']['id'] + row = { + 'client': name, + 'round': round_index, + 'warmup': round_index < 0, + 'start': time.perf_counter(), + 'relay_finished': asyncio.Event(), + } + active = row + if name == 'direct': + meter = SSEMeter() + async with client.stream('POST', local + '/chat/completions', json=canonical) as response: + response.raise_for_status() + async for chunk in response.aiter_bytes(): + meter.feed(chunk, time.perf_counter()) + row['consumer_first_content_ms'] = (meter.first_content - row['start']) * 1000 + elif name == 'langbot': + state = CallState(RequestPolicy(retries=0, total_timeout=90)) + token = current_call.set(state) + try: + async with contextlib.aclosing( + adapter.invoke_llm_stream( + None, model, messages, extra_args={'temperature': 0, 'max_tokens': 256} + ) + ) as stream: + async for chunk in stream: + if chunk.content and 'consumer_first_content_ms' not in row: + row['consumer_first_content_ms'] = ( + time.perf_counter() - row['start'] + ) * 1000 + row['preparation_ms'] = state.metrics.get('preparation_ms') + row['transport_setup_ms'] = state.metrics.get('transport_setup_ms') + row['client_usage'] = state.usage + finally: + current_call.reset(token) + else: + response = await client.post( + server + f'/api/session/{session_id}/prompt', + json={'text': canonical['messages'][-1]['content']}, + ) + response.raise_for_status() + await asyncio.wait_for(row['relay_finished'].wait(), 100) + if name != 'opencode': + row['consumer_total_ms'] = (time.perf_counter() - row['start']) * 1000 + await asyncio.wait_for(row['relay_finished'].wait(), 100) + if 'upstream_complete_ms' not in row: + raise RuntimeError(f'Incomplete benchmark response (HTTP {row.get("status", "unknown")})') + # Use one completion boundary for all clients. This + # relay cannot measure OpenCode's UI rendering latency. + row['total_ms'] = row['dispatch_to_wire_ms'] + row['upstream_complete_ms'] + row.pop('relay_finished') + row.pop('start') + row.pop('wire_started', None) + records.append(row) + active = None + print(json.dumps(row), flush=True) + await asyncio.sleep(0.1) + report = { + 'model': options.model, + 'base_url': base_url, + 'mock': options.mock, + 'long_prefix': options.long_prefix, + 'cache_control': options.cache_control, + 'baseline_no_drain': options.baseline_no_drain, + 'canonical_sha256': digest(canonical), + 'canonical_bytes': len(payload), + 'python_import_ms': import_ms, + 'opencode_startup_ms': startup_ms, + 'litellm': importlib.metadata.version('litellm'), + 'opencode': options.opencode_version, + 'records': records, + 'summary': summary(records), + } + Path(options.output).write_text(json.dumps(report, indent=2), encoding='utf-8') + print('SUMMARY', json.dumps(report['summary']), flush=True) + finally: + if process and process.returncode is None: + process.terminate() + await process.wait() + log.close() + await adapter.aclose() + await upstream.aclose() + await runner.cleanup() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--opencode', required=True) + parser.add_argument('--opencode-version', default='unknown') + parser.add_argument('--model', default='claude-haiku-4.5') + parser.add_argument('--base-url') + parser.add_argument('--credentials-file') + parser.add_argument('--api-key-env', default='LLM_BENCHMARK_API_KEY') + parser.add_argument('--rounds', type=int, default=6) + parser.add_argument('--mock', action='store_true') + parser.add_argument('--long-prefix', action='store_true') + parser.add_argument('--cache-control', action='store_true') + parser.add_argument('--baseline-no-drain', action='store_true', help='Reproduce the pre-fix stream transport') + parser.add_argument('--output', required=True) + args = parser.parse_args() + if not 1 <= args.rounds <= 12: + parser.error('--rounds must be between 1 and 12 (plus one warmup round)') + os.environ['NO_PROXY'] = ','.join(filter(None, [os.environ.get('NO_PROXY'), '127.0.0.1', 'localhost'])) + asyncio.run(run(args)) diff --git a/scripts/test-coverage.sh b/scripts/test-coverage.sh index 211ceae4d..d98c88063 100755 --- a/scripts/test-coverage.sh +++ b/scripts/test-coverage.sh @@ -1,7 +1,7 @@ #!/bin/bash # Coverage gate script -# Runs all tests with coverage, enforcing minimum coverage threshold +# Runs unit, smoke, and fast integration tests with a combined coverage threshold # Uses separate pytest invocations to avoid sys.modules pollution between test types set -euo pipefail @@ -9,57 +9,35 @@ set -euo pipefail echo "=== LangBot Coverage Gate ===" echo "" -# Coverage threshold (baseline from current coverage, conservative buffer) -# Current: ~22.14%, threshold: 18% +# Minimum combined coverage for unit, smoke, and fast integration tests COVERAGE_THRESHOLD=18 # Create temporary directory for coverage files COV_DIR=$(mktemp -d) -trap "rm -rf $COV_DIR" EXIT +trap 'rm -rf "$COV_DIR"' EXIT +export COVERAGE_FILE="$COV_DIR/.coverage" echo "[1/3] Running unit + smoke tests with coverage..." uv run pytest tests/unit_tests/ tests/smoke/ \ --cov=langbot \ - --cov-report=json:$COV_DIR/unit.json \ - --cov-report=term-missing \ + --cov-report= \ -q --tb=short echo "" echo "[2/3] Running fast integration tests with coverage..." uv run pytest tests/integration/ -m "not slow" \ --cov=langbot \ - --cov-report=json:$COV_DIR/integration.json \ - --cov-report=term-missing \ + --cov-append \ + --cov-report= \ -q --tb=short echo "" -echo "[3/3] Combining coverage reports..." -# Use coverage combine if available, otherwise just report total -if command -v coverage &> /dev/null; then - # Combine JSON reports - coverage combine --keep $COV_DIR/unit.json $COV_DIR/integration.json \ - --data-file=$COV_DIR/combined.data 2>/dev/null || true - - coverage report --data-file=$COV_DIR/combined.data || true -else - echo "Note: coverage combine not available, showing individual reports above" -fi - -# Generate final XML report for CI (from last run) -uv run pytest tests/unit_tests/ tests/smoke/ \ - --cov=langbot \ - --cov-report=xml:coverage.xml \ - --cov-report=term \ - --cov-fail-under=$COVERAGE_THRESHOLD \ - -q 2>/dev/null || { - # If threshold check fails on combined, check unit+smoke baseline - echo "" - echo "Coverage threshold: $COVERAGE_THRESHOLD%" - echo "Note: Full coverage requires running all test types separately" -} +echo "[3/3] Reporting combined coverage..." +uv run coverage xml -o coverage.xml +uv run coverage report --fail-under="$COVERAGE_THRESHOLD" echo "" echo "=== Coverage Gate Complete ===" echo "" echo "Coverage baseline: $COVERAGE_THRESHOLD%" -echo "Coverage report saved to coverage.xml" \ No newline at end of file +echo "Coverage report saved to coverage.xml" diff --git a/skills/scripts/e2e/onebot-runtime-probe.py b/skills/scripts/e2e/onebot-runtime-probe.py index a080571fa..c919803bf 100644 --- a/skills/scripts/e2e/onebot-runtime-probe.py +++ b/skills/scripts/e2e/onebot-runtime-probe.py @@ -6,14 +6,15 @@ from __future__ import annotations import argparse import asyncio import json +import os import time import websockets -async def run(port: int) -> None: - uri = f'ws://127.0.0.1:{port}/ws' +async def run(uri: str) -> None: headers = { + 'Authorization': 'Bearer ' + os.environ['ONEBOT_PROBE_TOKEN'], 'X-Self-ID': '900001', 'X-Client-Role': 'Universal', 'User-Agent': 'LangBot-E2E-OneBot/1.0', @@ -27,7 +28,7 @@ async def run(port: int) -> None: except OSError: await asyncio.sleep(0.25) if connection is None: - raise RuntimeError(f'OneBot reverse WebSocket did not open on port {port}') + raise RuntimeError('OneBot reverse WebSocket did not open') actions: list[str] = [] event_deadline = time.monotonic() + 20 @@ -88,6 +89,6 @@ async def run(port: int) -> None: if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument('--port', type=int, required=True) + parser.add_argument('--url', required=True) args = parser.parse_args() - asyncio.run(run(args.port)) + asyncio.run(run(args.url)) diff --git a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs index dc94efe31..29f5b5506 100644 --- a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs +++ b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { createServer } from "node:net"; +import { randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -60,19 +60,6 @@ const result = { evidence_collected: ["ui", "screenshot", "console", "api_diagnostic"], }; -async function getFreePort() { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - await new Promise((resolve) => server.close(resolve)); - if (!port) throw new Error("Could not allocate a temporary OneBot port."); - return port; -} - async function api(page, path, options = {}) { return await page.evaluate( async ({ baseUrl, path, options, token }) => { @@ -96,7 +83,7 @@ async function api(page, path, options = {}) { ); } -async function runProbe(port) { +async function runProbe(url, accessToken) { const repo = await resolveLangBotRepo(); const python = join(repo, ".venv", "bin", "python"); const script = join( @@ -107,8 +94,9 @@ async function runProbe(port) { throw new Error(`LangBot virtualenv Python is missing: ${python}`); } return await new Promise((resolve, reject) => { - const child = spawn(python, [script, "--port", String(port)], { + const child = spawn(python, [script, "--url", url], { cwd: repo, + env: { ...process.env, ONEBOT_PROBE_TOKEN: accessToken }, }); let stdout = ""; let stderr = ""; @@ -132,7 +120,7 @@ async function runProbe(port) { try { if (!frontendUrl) throw new Error("LANGBOT_FRONTEND_URL is not configured."); if (!backendUrl) throw new Error("LANGBOT_BACKEND_URL is not configured."); - const port = await getFreePort(); + const accessToken = randomBytes(32).toString("hex"); browser = await createBrowser(paths); const { page } = browser; @@ -190,9 +178,7 @@ try { .getByText(/Configure Your Bot|配置机器人|ボットを設定/) .first() .waitFor({ timeout: 15_000 }); - const portInput = page.getByRole("spinbutton").first(); - await portInput.fill(String(port)); - await portInput.press("Tab"); + await page.getByLabel(/Access token|访问令牌|アクセストークン/i).fill(accessToken); await page .getByRole("button", { name: /Save & Enable Bot|保存并启用|保存して有効化/, @@ -270,7 +256,7 @@ try { } result.visible_signals.push("route-persisted"); - result.runtime.onebot = await runProbe(port); + result.runtime.onebot = await runProbe(`${backendUrl.replace(/^http/, "ws").replace(/\/$/, "")}/bots/${botId}/onebot/v11/ws`, accessToken); let routeStatus; for (let attempt = 0; attempt < 30; attempt += 1) { routeStatus = await api( diff --git a/skills/skills.index.json b/skills/skills.index.json index 3e8695455..25d64cf20 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -1805,7 +1805,7 @@ }, { "id": "sandbox-skill-authoring-e2e", - "title": "Local Agent creates, registers, activates, and uses a sandbox skill", + "title": "Local Agent publishes and executes an immutable Skill revision", "mode": "agent-browser", "area": "sandbox", "type": "regression", @@ -1817,9 +1817,7 @@ "skills", "local-agent", "tools", - "e2b", - "nsjail", - "host" + "nsjail" ], "automation": "", "setup_automation": [], @@ -1833,7 +1831,7 @@ }, { "id": "sandbox-skill-authoring-edit-existing-e2e", - "title": "Local Agent modifies an activated sandbox skill package", + "title": "Local Agent publishes a new Skill revision without changing an active run", "mode": "agent-browser", "area": "sandbox", "type": "regression", @@ -1845,17 +1843,12 @@ "skills", "local-agent", "tools", - "edit" - ], - "automation": "scripts/e2e/pipeline-debug-chat.mjs", - "setup_automation": [ - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env", - "node:scripts/e2e/reset-sandbox-skill.mjs --name=lb-skill-edit-regression" - ], - "setup_provides_env": [ - "LANGBOT_LOCAL_AGENT_PIPELINE_URL", - "LANGBOT_LOCAL_AGENT_PIPELINE_NAME" + "edit", + "nsjail" ], + "automation": "", + "setup_automation": [], + "setup_provides_env": [], "evidence_required": [ "ui", "backend_log", diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md index 2f61af768..7661e7647 100644 --- a/skills/skills/langbot-deploy/SKILL.md +++ b/skills/skills/langbot-deploy/SKILL.md @@ -7,6 +7,16 @@ description: Deploy and configure a LangBot instance — Docker / Docker Compose Covers running LangBot in production. For development see `langbot-dev`. +Read https://langbot.app/llms.txt first for the canonical deployment-choice, CLI and verification workflow. The summary below applies when selecting hosting. + +## Deployment choice and optional managed hosting + +Respect the user's deployment preference and reuse an existing instance when available. Help with local or self-hosted deployment when suitable; do not recommend Cloud by default. + +Offer [LangBot Cloud](https://space.langbot.app/cloud) when the user wants managed hosting without maintaining a server, the local environment is unsuitable, or a deployment problem remains difficult after reasonable troubleshooting. Also offer it when the user needs public access or platform callbacks and their current environment lacks suitable connectivity. Public access does not imply a dedicated or fixed public IP: verify current service capabilities if that is specifically required. + +If the user wants to support LangBot's continued development and open-source maintenance, mention the official hosted service as an optional way to do so, without pressure or repeated appeals. Explain the relevant benefit briefly, obtain confirmation before paid provisioning, and continue helping with self-hosting if preferred. Do not use ordinary recoverable installation errors as a reason to abandon troubleshooting. + ## Docker Compose (recommended) ```bash diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index d96e56f2f..ec47b8bb1 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -60,6 +60,19 @@ To inspect key identity and permissions, call `GET /api/v1/system/context` with ## Tool surface +Slack quick setup: create a disabled `slack-omni` bot draft first, then use +`start_slack_setup` with the user's App Configuration access/refresh tokens. +Choose `socket_mode=true` with a browser-reachable OAuth `redirect_url`, or +provide the public HTTPS `webhook_url` ending in `/bots/`. +Poll `get_slack_setup_status` for the Slack installation authorization URL; +the user must authorize installation. On success, apply the returned `config` +using the normal bot update flow. Socket Mode also requires a separately +generated `app_token` (`xapp-`, scope `connections:write`). Never log tokens. +`cancel_slack_setup` removes the temporary session without deleting the Slack +application. Sessions expire after 15 minutes and are bound to the initiating +Workspace, principal, and placement generation. Restarting a failed setup can +create another Slack application; inspect the returned `app_id` first. + The tools wrap the LangBot service layer. Current tools (v1): | Tool | Purpose | @@ -174,3 +187,84 @@ Legacy EventListener plugins remain in the Pipeline lifecycle. `list_processor_runs` includes `created_at_ms`, `started_at_ms`, and `finished_at_ms`: Host lifecycle times in epoch milliseconds. Use the start and finish times for elapsed processing time; select a run and call `get_processor_run_events` for its logs and action results. These times are not internal plugin profiling data. + +## Unified execution monitoring + +Use `get_inflight_executions` for a bounded current-workspace snapshot of active +and recently started executions, including `progress_event`. Progress is a +reported stage, not an estimated completion percentage. The UI uses the shared +`GET /api/v1/monitoring/in-flight/stream` SSE feed instead of polling per viewer. + +Use `get_monitoring_executions` for the execution list and +its legacy `pipeline_ids` parameter to filter any processor kind (Agent, +Pipeline or event processor); the summary uses the same processor scope. Use +`get_monitoring_execution_detail` with `source=auto` to resolve a run, +message or event identifier outside the current list page. The detail exposes +`inputs`, `outputs` (generated content), `deliveries` (recorded platform sends), +`events`, `llm_calls`, `tool_calls`, `errors`, `related`, and `conversation` in +`pages`. Follow each section's `has_more` and `next_offset` independently. +Start with `section=overview` to load only inputs, outputs, deliveries and errors. +Request `conversation`, `related`, calls and events separately when needed; +omitting `section` retains the complete trace response for existing consumers. +Conversation history supplies context; historical messages without explicit +links must not be asserted to belong to the selected execution. Events without +a processor run use `source=event`. All lookups remain Workspace-scoped. + +Monitoring record filters accept `mode` (`all`, `real`, `debug`) and +`execution_statuses` (normalized execution statuses). These select the owning +execution, not the individual model/tool call outcome. Calls without a recorded +execution link are excluded when an execution filter is active. + +### Workspace message language + +`get_workspace_message_language` reads the current Workspace's backend language +(`workspace.view`). `set_workspace_message_language(language)` requires +`workspace.update`; supported values are `en_US`, `zh_Hans`, `zh_Hant`, `ja_JP`, +`es_ES`, `ru_RU`, `th_TH`, `vi_VN`. HTTP equivalents are GET/PUT +`/api/v1/workspaces/current/language` (PUT body: `{"language":"ja_JP"}`). +The API key's Workspace remains authoritative. The setting controls new backend +messages and default bot notices, independently of personal UI language. +Pipeline `output.misc` hint fields override defaults; empty values inherit this +setting. Existing custom text and historical logs are not rewritten. + +### Workspace default LLM model + +`get_starred_model` reads the Workspace-wide favorite. `set_starred_model` +requires `provider_secret.manage` and replaces the single starred model; pass +`null` to clear it. The model must belong to the current Workspace. +`get_default_model` resolves the favorite first, then the Space wizard chat +recommendation only when LangBot Models is enabled and the Workspace owner is +bound to a LangBot Account. It returns a nullable `uuid`. +New processor LLM selector defaults use this preference; existing configurations +are not rewritten. Embedding and rerank models are not eligible. + +### Reset a bot session context + +Use `reset_session_context(bot_id, session_id)` only when a user asks to start a session afresh. It requires `resource.manage`, preserves monitoring records, and refuses sessions with active tasks. It clears conversation runner state and excludes earlier transcript entries from subsequent model context. Files and long-term memory are not removed. + +## Cloud plan platform restrictions + +Bot creation and enabling use the same Workspace entitlement policy as HTTP. A disabled platform is rejected even through MCP or plugin calls; do not retry using an Omni or legacy alias to bypass the restriction. The Workspace-scoped HTTP adapter catalog omits disabled platforms. + +## LLM request diagnostics + +`get_monitoring_records(kind="llm_calls")` includes nullable `details` on each call. +Read `details.timing`, `details.usage`, and `details.error` to distinguish provider +latency, retry waits, cached/reasoning tokens, and failures. Timing values are +milliseconds since invocation start; `first_event_ms` includes reasoning/control +frames, while `first_content_ms` measures the first upstream content delta. +`first_visible_chunk_ms` may contain displayed reasoning, not just answer text. +Old records have no details. Do not equate total duration with provider TTFT. +See `docs/LLM_REQUEST_POLICY.md` for policy precedence and gateway header behavior. + +`get_llm_model` also exposes optional `reasoning_capabilities.budget_tokens` +min/max bounds. Numeric thinking budgets use `reasoning_config.budget_tokens` +with level `enabled`; do not combine budgets with effort levels or native thinking +arguments. Existing Runner reasoning overrides take precedence. Prompt caching +is opt-in through model/call `extra_args.prompt_caching`; use provider-specific +modes documented in `docs/LLM_REQUEST_POLICY.md`. Cache keys are routing hints, +not evidence of hits; read the actual usage counters. `preparation_ms` measures +request construction and `preparation_cache_hits` counts retry preparation reuse. +`transport_setup_ms` measures managed pool acquisition, including first-use TLS +initialization; it is not network TTFT. See `docs/LLM_LATENCY_BENCHMARK.md` for +controlled request-layer measurements and their scope. diff --git a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml index 608266c4e..cc3355b2e 100644 --- a/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml +++ b/skills/skills/langbot-testing/cases/sandbox-skill-authoring-e2e.yaml @@ -1,5 +1,5 @@ id: sandbox-skill-authoring-e2e -title: "Local Agent creates, registers, activates, and uses a sandbox skill" +title: "Local Agent publishes and executes an immutable Skill revision" mode: agent-browser area: sandbox type: regression @@ -11,45 +11,32 @@ tags: - skills - local-agent - tools - - e2b - nsjail - - host skills: - langbot-env-setup - langbot-testing env: - LANGBOT_FRONTEND_URL - LANGBOT_BACKEND_URL - - LANGBOT_LOCAL_AGENT_PIPELINE_URL - - LANGBOT_LOCAL_AGENT_PIPELINE_NAME +env_any: + - 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 Box backend intended for this run, such as e2b, nsjail, or explicit host development mode." - - "The selected model route supports tool/function calling strongly enough to invoke sandbox tools." + - "The selected Local Agent uses a function-calling model and an available Box backend." 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." + - "Open the target Local Agent in Debug Chat." + - "Follow Publish and execute in references/sandbox-skill-authoring.md with a unique Skill name." + - "Record the activated revision and the script's independently expected output." + - "Attempt a write under the published package, then read back the original contents." 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, nsjail, or host." - - "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md." - - "Box status: recent_error_count is 0 after the run." + - "UI and logs show successful draft creation, registration, activation and script execution." + - "Script output matches the deterministic expected value." + - "Published-package mutation is rejected; outputs remain in the run outbox or writable draft." + - "The expected rejected write is distinguishable from unexpected execution failures." 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 + - "Check Box availability and backend selection before interpreting a missing tool." + - "The runner may acquire Box before activation; a new package must not recreate the session." 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 index 34590804f..dc2cdb0ce 100644 --- 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 @@ -1,5 +1,5 @@ id: sandbox-skill-authoring-edit-existing-e2e -title: "Local Agent modifies an activated sandbox skill package" +title: "Local Agent publishes a new Skill revision without changing an active run" mode: agent-browser area: sandbox type: regression @@ -12,66 +12,35 @@ tags: - local-agent - tools - edit + - nsjail 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: "E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK" -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_OK:lb-skill-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":"E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK","response_timeout_ms":"240000"}]' -setup_automation: - - "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env" - - "node:scripts/e2e/reset-sandbox-skill.mjs --name=lb-skill-edit-regression" -setup_provides_env: - - LANGBOT_LOCAL_AGENT_PIPELINE_URL - - LANGBOT_LOCAL_AGENT_PIPELINE_NAME -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","json_equals":{"numbers":[5,6],"factors":[7,8],"marker":"UPDATED_MARKER_EDIT_REGRESSION"}},{"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" +env_any: + - 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." + - "The selected Local Agent uses a function-calling model and an available Box backend." + - "A unique test Skill v1 has a deterministic script and reference output." 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." + - "Follow Update an existing Skill in references/sandbox-skill-authoring.md." + - "Keep one run active on v1; edit a writable draft and publish v2 using base_revision=v1." + - "Read and execute again in the existing run, then activate and execute in a new run in the same conversation." + - "Attempt a conflicting update using the stale base_revision and verify rejection." + - "Follow Shared Box and recovery to check retained temporary state and a managed process." checks: - - "UI: A Bot message, not only the user prompt, contains E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK." - - "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." - - "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." + - "The existing run continues to read and execute v1; the new run reads and executes v2." + - "Published revisions remain immutable, and stale publication is rejected." + - "Alternating activated revisions do not recreate the shared Box or stop the managed process." + - "An unbound Skill is not restored into a subsequent run." 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 + - "An output marker in the user prompt alone is not execution evidence." + - "Do not edit files in the published path; publish a writable draft explicitly." + - "Expired run tool sessions must fail explicitly, never silently select the latest Skill." diff --git a/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml index 220c5b3f5..de4d56cb0 100644 --- a/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml +++ b/skills/skills/langbot-testing/cases/wizard-onebot-agent-runtime.yaml @@ -32,7 +32,7 @@ preconditions: - "The LangBot virtualenv includes the websockets package used by the local OneBot probe." steps: - "Open Quick Start and choose Welcome new members with the OneBot v11 channel." - - "Create and enable a temporary OneBot Bot on an available local port." + - "Create and enable a temporary OneBot Bot using the main-port reverse WS URL and a generated access token." - "Select Local Agent and confirm the editable welcome prompt is prefilled." - "Choose QA Deterministic Runner and finish the wizard." - "Connect a local OneBot reverse WebSocket and send a group_increase notice." diff --git a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md index 1d4b9a02f..8f2ad871a 100644 --- a/skills/skills/langbot-testing/references/sandbox-skill-authoring.md +++ b/skills/skills/langbot-testing/references/sandbox-skill-authoring.md @@ -1,139 +1,60 @@ -# Sandbox Skill Authoring +# Skill publication and execution acceptance -## 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, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence. +Verify through Debug Chat that instructions and text resources work without Box, +and that executable packages use the revision activated by the current run. +API and direct backend checks diagnose failures; they do not establish UI acceptance. ## 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. - - `BOX_BACKEND=host` only when validating explicit, trusted local direct execution. -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. +Use the frontend/backend and Local Agent pipeline selected in the machine's +`skills/.env.local`. The model must support tool calling. Do not record credentials. +Use Docker/Podman, nsjail, or a configured E2B service. The runner may acquire an +empty Box before activation; adding a revision must preserve that Box. Host mode +provides no sandbox isolation evidence. -Do not store sandbox provider keys, JWTs, OAuth tokens, or localStorage values in the case or notes. +## Read-only path -## Debug Chat Prompt Pattern +Install a unique Skill with instructions and a UTF-8 reference, then disable Box. +Ask the Agent to activate it and read the reference. Check the reported revision, +reference contents, and absence of sandbox creation. Execution requests must fail +clearly; activation must not claim that files are already mounted. -Use a unique skill name per run, for example: +## Publish and execute -```text -lb-sandbox-agent-e2e- -``` +1. With Box enabled, create a draft under `/workspace/skill-drafts/`. + Include `SKILL.md`, `scripts/use.py`, and `data/input.json`. Resolve resource + files relative to `__file__`; the script should print a deterministic marker. +2. Call `register_skill` with the draft path, then `activate`. Record revision v1. +3. Execute the script under the `mount_path` returned by activation. Write results to the + run's outbox or draft directory, never the published package. +4. Attempt to overwrite a published file. The operation must fail and its contents + must remain unchanged. Check UI results, tool logs, and the published package. -Send a prompt that requires the model to do all of the following: +## Update an existing Skill -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: +Copy v1 to a separate writable draft. Modify the draft and publish with +`base_revision=v1`; record v2. The existing run must continue reading/executing v1, +including when `activate` is called again. A new run in the same conversation must +activate v2. A stale `base_revision` update must fail. Removing the Skill from the +new run's authorization must not break startup or restore the old activation. +Verify a second UI edit/save adopts the revision returned by the first save. - ```text - SANDBOX_COMPLEX_SKILL_OK sum=10 product=24 - ``` +## Shared Box and recovery -4. Call `register_skill` with `path=/workspace/`. -5. Call `activate` with `skill_name=`. -6. Call `exec` with `workdir=/workspace/.skills/` and run: +With nsjail, alternate two authorized runs using v1 and v2 in the same Box. Check +each script's independent version marker, retained `/tmp` state, and a running +managed process. Neither activation nor a changed mount set may recreate the Box. +Docker/Podman and E2B must likewise add v2 at its digest path while v1 and the +existing Box remain usable. On fixed-mount backends, changing ordinary host mounts must fail without +removing the session. Programs sharing a Box can see its cached packages; test +version consistency without claiming that the shared filesystem isolates runs. - ```bash - python3 scripts/use.py && echo SANDBOX_ACTIVATED_WRITEBACK_OK > activated_writeback.txt && cat activated_writeback.txt - ``` +Pins are Host-owned run ledger data, not conversation state. An active run whose +in-memory pin is reloaded must use its recorded revision, even after publication +or deletion of the current catalog entry. A missing recorded package must fail. +A Core process restart expires tool sessions; calls from those sessions must be +rejected. Do not report this as automatic run recovery. -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`, `backend=host`, 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. -- The host backend does not honor sandbox image, network, rootfs, process, or - resource isolation. Use a disposable workspace and low-privilege account. -- 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` +Capture the visible assistant results, relevant tool logs, revisions, and file +contents. Public E2B credentials and deployment topology are separate prerequisites; +mocked or local backend results do not establish E2B service acceptance. diff --git a/src/langbot/libs/dify_service_api/v1/client_test.py b/src/langbot/libs/dify_service_api/v1/client_test.py deleted file mode 100644 index 2695b2ea4..000000000 --- a/src/langbot/libs/dify_service_api/v1/client_test.py +++ /dev/null @@ -1,17 +0,0 @@ -from . import client - -import asyncio - -import os - - -class TestDifyClient: - async def test_chat_messages(self): - cln = client.DifyClient(api_key=os.getenv('DIFY_API_KEY')) - - resp = await cln.chat_messages(inputs={}, query='Who are you?', user_id='test') - print(resp) - - -if __name__ == '__main__': - asyncio.run(TestDifyClient().test_chat_messages()) diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index d6a4914ce..48032b72a 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -14,6 +14,7 @@ import websockets from .EchoHandler import EchoTextHandler from .card_callback import DingTalkCardActionHandler from .dingtalkevent import DingTalkEvent +from .thinking import card_text_fields, visible_think_markers import httpx import traceback from langbot.pkg.utils import httpclient @@ -74,6 +75,16 @@ def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict: return result +def is_stream_card_configured(config: dict) -> bool: + if not config.get('markdown_card', True): + return False + template_id = config.get('card_template_id') + # Older manifests stored instructional placeholder text as the default. + return ( + isinstance(template_id, str) and bool(template_id.strip()) and template_id.strip() != '填写你的卡片template_id' + ) + + class DingTalkClient: _MAX_INBOUND_TASKS = 100 @@ -108,7 +119,7 @@ class DingTalkClient: } self.access_token = '' self.robot_name = robot_name - self.robot_code = robot_code + self.robot_code = (robot_code or '').strip() or client_id self.access_token_expiry_time = '' self.markdown_card = markdown_card self.logger = logger @@ -258,28 +269,105 @@ class DingTalkClient: async def update_incoming_message(self, message): """异步更新 DingTalkClient 中的 incoming_message""" + # Resolve identity before downloading any attachments from this event. + robot_code = getattr(message, 'robot_code', None) + if isinstance(robot_code, str) and robot_code.strip(): + self.robot_code = robot_code.strip() message_data = await self.get_message(message) if message_data: event = DingTalkEvent.from_payload(message_data) if event: await self._handle_message(event) + @staticmethod + def _check_send_result(response): + # HTTP 200 can still contain a rejected DingTalk message. + data = response.json() + if response.status_code != 200 or data.get('errcode', 0) != 0 or data.get('code'): + raise RuntimeError( + f'DingTalk delivery failed (HTTP {response.status_code}, ' + f'code={data.get("errcode", data.get("code", "unknown"))}): ' + f'{data.get("errmsg", data.get("message", "Unknown error"))}' + ) + return data + async def send_message(self, content: str, incoming_message, at: bool): + if not content.strip(): + return {} + content = visible_think_markers(content, self.markdown_card) if self.markdown_card: - if at: - self.EchoTextHandler.reply_markdown( - title='@' + incoming_message.sender_nick + ' ' + content, - text='@' + incoming_message.sender_nick + ' ' + content, - incoming_message=incoming_message, - ) - else: - self.EchoTextHandler.reply_markdown( - title=content, - text=content, - incoming_message=incoming_message, - ) + text = ('@' + incoming_message.sender_nick + ' ' if at else '') + content + payload = {'msgtype': 'markdown', 'markdown': {'title': text[:80], 'text': text}} else: - self.EchoTextHandler.reply_text(content, incoming_message) + payload = {'msgtype': 'text', 'text': {'content': content}} + payload['at'] = {'atUserIds': [incoming_message.sender_staff_id]} + async with self._http_client_context() as client: + response = await client.post(incoming_message.session_webhook, json=payload, timeout=30.0) + return self._check_send_result(response) + + async def send_attachment(self, component, incoming_message): + from langbot_plugin.api.entities.builtin.platform.message import Image + + is_image = isinstance(component, Image) + name = getattr(component, 'name', '') or ('image.png' if is_image else 'file') + name = os.path.basename(name) + encoded = getattr(component, 'base64', None) + if encoded: + encoded = encoded.split(',', 1)[-1] + if len(encoded) > 4 * ((_MAX_MEDIA_BYTES + 2) // 3): + raise ValueError('DingTalk media exceeds the size limit') + body = base64.b64decode(encoded, validate=True) + elif getattr(component, 'path', None): + body = await asyncio.to_thread(_read_local_media_limited, component.path) + elif getattr(component, 'url', None): + async with self._http_client_context() as client: + async with client.stream('GET', component.url, timeout=30.0) as response: + response.raise_for_status() + body = await _read_httpx_media_limited(response) + else: + raise ValueError('DingTalk attachment has no readable content') + if len(body) > _MAX_MEDIA_BYTES: + raise ValueError('DingTalk media exceeds the size limit') + token = await self.get_legacy_access_token() + if not token: + raise RuntimeError('DingTalk media authentication failed') + async with self._http_client_context() as client: + response = await client.post( + 'https://oapi.dingtalk.com/media/upload', + params={'access_token': token, 'type': 'image' if is_image else 'file'}, + files={'media': (name, body)}, + timeout=30.0, + ) + uploaded = self._check_send_result(response) + media_id = uploaded.get('media_id') + if not media_id: + raise RuntimeError('DingTalk media upload returned no media ID') + if not await self.check_access_token(): + await self.get_access_token() + params = ( + {'photoURL': media_id} + if is_image + else {'mediaId': media_id, 'fileName': name, 'fileType': os.path.splitext(name)[1].lstrip('.')} + ) + payload = { + 'robotCode': self.robot_code or self.key, + 'msgKey': 'sampleImageMsg' if is_image else 'sampleFile', + 'msgParam': json.dumps(params, ensure_ascii=False), + } + if str(incoming_message.conversation_type) == '2': + endpoint = 'groupMessages/send' + payload['openConversationId'] = incoming_message.conversation_id + else: + endpoint = 'oToMessages/batchSend' + payload['userIds'] = [incoming_message.sender_staff_id] + async with self._http_client_context() as client: + response = await client.post( + f'{DINGTALK_OPENAPI_BASE}/v1.0/robot/{endpoint}', + headers={'x-acs-dingtalk-access-token': self.access_token}, + json=payload, + timeout=30.0, + ) + return self._check_send_result(response) async def get_incoming_message(self): """获取收到的消息""" @@ -562,7 +650,7 @@ class DingTalkClient: 'robotCode': robot_code, 'userIds': [target_id], 'msgKey': 'sampleText', - 'msgParam': json.dumps({'content': content}), + 'msgParam': json.dumps({'content': visible_think_markers(content, False)}), } _stdout_logger.info( 'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d', @@ -606,7 +694,7 @@ class DingTalkClient: 'robotCode': self.robot_code or self.key, 'openConversationId': target_id, 'msgKey': 'sampleText', - 'msgParam': json.dumps({'content': content}), + 'msgParam': json.dumps({'content': visible_think_markers(content, False)}), } try: async with self._http_client_context() as client: @@ -627,14 +715,13 @@ class DingTalkClient: temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False, - card_auto_layout: bool = False, + card_auto_layout: bool = True, ): """Create + deliver the streaming chat card for a chatbot reply. Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns - `(None, out_track_id)` to keep call sites compatible with the - previous `(card_instance, card_instance_id)` shape — the first slot - is unused now that everything is driven by out_track_id. + `(layout_config, out_track_id)` keeps the adapter's tuple contract + while preserving the layout setting on subsequent data updates. """ out_track_id = uuid.uuid4().hex is_group = str(incoming_message.conversation_type) == '2' @@ -643,7 +730,14 @@ class DingTalkClient: else: open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' - card_param_map = {'content': ''} + card_param_map = { + 'content': '', + 'answer': '', + 'reasoning': '', + 'flowStatus': '1', + 'brand': '', + 'hasReasoning': '', + } if incoming_message.message_type == 'text': card_param_map['query'] = incoming_message.get_text_list()[0] else: @@ -657,30 +751,21 @@ class DingTalkClient: card_param_map=card_param_map, card_data_config={'autoLayout': card_auto_layout}, ) - return None, out_track_id + return {'autoLayout': card_auto_layout}, out_track_id async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): - """Stream a single chunk into an existing card's `content` field.""" - try: - await self.streaming_update_card( - out_track_id=card_instance_id, - content_key='content', - content_value=content, - append=False, - finished=is_final, - failed=False, - ) - except Exception as e: - if self.logger: - self.logger.exception(e) - await self.streaming_update_card( - out_track_id=card_instance_id, - content_key='content', - content_value='', - append=False, - finished=is_final, - failed=True, - ) + """Update separate reasoning/answer fields and the legacy content field.""" + fields = card_text_fields(content) + # Old templates continue to render content with explicit think markers; + # the supplied split template binds only reasoning and answer. + fields['content'] = visible_think_markers(content) + fields['flowStatus'] = '3' if is_final else '2' + fields['brand'] = '' + if isinstance(card_instance, dict): + fields['config'] = card_instance + fields['autoLayout'] = str(card_instance.get('autoLayout', True)).lower() + if not await self.update_card_data(out_track_id=card_instance_id, card_param_map=fields): + raise RuntimeError('DingTalk card content update failed; see adapter logs for the API response') async def create_and_deliver_card( self, @@ -710,6 +795,7 @@ class DingTalkClient: template_params = dict(card_param_map or {}) if card_data_config is not None: template_params['config'] = card_data_config + template_params['autoLayout'] = str(card_data_config.get('autoLayout', True)).lower() cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)} body: dict = { @@ -743,34 +829,17 @@ class DingTalkClient: 'Content-Type': 'application/json', } try: - _stdout_logger.info( - 'DingTalk createAndDeliver request body: %s', - json.dumps(body, ensure_ascii=False)[:1500], - ) async with self._http_client_context() as client: response = await client.post(url, headers=headers, json=body, timeout=30.0) - response_body = await httpclient.response_text(response, max_chars=500) - if response.status_code == 200: - _stdout_logger.info( - 'DingTalk createAndDeliver response: %s', - response_body, - ) - return True - _stdout_logger.error( - 'DingTalk createAndDeliver failed: status=%s body=%s', - response.status_code, - response_body, - ) - if self.logger: - await self.logger.error( - f'DingTalk createAndDeliver failed: status={response.status_code} body={response_body}' - ) - return False - except Exception: - _stdout_logger.exception('DingTalk createAndDeliver error') - if self.logger: - await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}') - return False + # Propagate business failures as well as transport failures. Callers + # must not register or update a card that was never delivered. + self._check_send_result(response) + except Exception as exc: + error = RuntimeError(f'DingTalk card creation failed: {exc}') + _stdout_logger.error('%s', error) + raise error from exc + _stdout_logger.info('DingTalk card created: outTrackId=%s', out_track_id) + return True async def streaming_update_card( self, diff --git a/src/langbot/libs/dingtalk_api/thinking.py b/src/langbot/libs/dingtalk_api/thinking.py new file mode 100644 index 000000000..d4a8b54ee --- /dev/null +++ b/src/langbot/libs/dingtalk_api/thinking.py @@ -0,0 +1,39 @@ +"""Render reasoning markers without changing the pipeline output policy.""" + +import re + +_MARKER = re.compile(r'', re.IGNORECASE) + + +def visible_think_markers(content: str, markdown: bool = True) -> str: + def replace(match): + tag = match.group(1).lower() + separator = '\n\n' if markdown else '\n' + return separator + (f'`{tag}`' if markdown else tag) + separator + + # Normalize only marker boundaries; preserve paragraph spacing in the body. + return re.sub(r'[ \t\r\n]*()[ \t\r\n]*', replace, content, flags=re.IGNORECASE).strip() + + +def card_text_fields(content: str) -> dict[str, str]: + thinking, answer = [], [] + inside = False + offset = 0 + for match in _MARKER.finditer(content): + (thinking if inside else answer).append(content[offset : match.start()]) + inside = match.group(0).lower() == '' + offset = match.end() + tail = content[offset:] + # A stream can stop in the middle of an opening or closing marker. + for size in range(min(len(tail), 7), 0, -1): + if any(marker.startswith(tail[-size:].lower()) for marker in ('', '')): + tail = tail[:-size] + break + (thinking if inside else answer).append(tail) + reasoning = '\n'.join(part.strip() for part in thinking if part.strip()) + response = '\n'.join(part.strip() for part in answer if part.strip()) + return { + 'reasoning': '> ' + reasoning.replace('\n', '\n> ') if reasoning else '', + 'answer': response, + 'hasReasoning': 'true' if reasoning else '', + } diff --git a/src/langbot/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py index 10508b097..5daf36a6c 100644 --- a/src/langbot/libs/official_account_api/api.py +++ b/src/langbot/libs/official_account_api/api.py @@ -5,6 +5,7 @@ from langbot.libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt import xml.etree.ElementTree as ET from quart import Quart, request import hashlib +import hmac from typing import Callable from langbot.libs.official_account_api.oaevent import OAEvent @@ -24,6 +25,39 @@ xml_template = """ _MAX_CALLBACK_BODY_BYTES = 1024 * 1024 +async def _read_callback_xml(client, req): + body = await req.data + if len(body) > _MAX_CALLBACK_BODY_BYTES: + raise ValueError('Official Account callback body exceeds the size limit') + root = ET.fromstring(body) + if root.find('Encrypt') is not None: + crypt = WXBizMsgCrypt(client.token, client.aes, client.appid) + code, xml = await asyncio.to_thread( + crypt.DecryptMsg, + body, + req.args.get('msg_signature', ''), + req.args.get('timestamp', ''), + req.args.get('nonce', ''), + ) + if code != 0: + raise ValueError('Official Account callback decryption failed') + return xml.decode('utf-8') if isinstance(xml, bytes) else xml + digest = hashlib.sha1( + ''.join( + sorted( + [ + client.token, + req.args.get('timestamp', ''), + req.args.get('nonce', ''), + ] + ) + ).encode('utf-8') + ).hexdigest() + if not hmac.compare_digest(digest, req.args.get('signature', '')): + raise ValueError('Official Account callback signature verification failed') + return body.decode('utf-8') + + class OAClient: _STATE_TTL_SECONDS = 600 _STATE_MAX = 4096 @@ -63,6 +97,7 @@ class OAClient: } self.access_token_expiry_time = None self.msg_id_map = {} + self._processing_tasks: set[asyncio.Task] = set() self.generated_content = {} self._msg_seen_at = {} self._generated_at = {} @@ -91,6 +126,9 @@ class OAClient: self._generated_at.pop(message_id, None) def clear(self) -> None: + for task in self._processing_tasks: + task.cancel() + self._processing_tasks.clear() self.msg_id_map.clear() self.generated_content.clear() self._msg_seen_at.clear() @@ -155,28 +193,17 @@ class OAClient: ) return 'signature verification failed', 403 elif req.method == 'POST': - encryt_msg = await req.data - if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES: - raise ValueError('Official Account callback body exceeds the size limit') - wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) - ret, xml_msg = await asyncio.to_thread( - wxcpt.DecryptMsg, - encryt_msg, - msg_signature, - timestamp, - nonce, - ) - xml_msg = xml_msg.decode('utf-8') - - if ret != 0: - await self.logger.error('消息解密失败') - raise Exception('消息解密失败') + xml_msg = await _read_callback_xml(self, req) message_data = await self.get_message(xml_msg) if message_data: event = OAEvent.from_payload(message_data) if event: - await self._handle_message(event) + from langbot.pkg.core.task_boundary import create_detached_task + + task = create_detached_task(self._process_message(event)) + self._processing_tasks.add(task) + task.add_done_callback(self._processing_tasks.discard) root = await asyncio.to_thread(ET.fromstring, xml_msg) from_user = root.find('FromUserName').text # 发送者 @@ -192,7 +219,7 @@ class OAClient: to_user=from_user, from_user=to_user, create_time=int(time.time()), - content=content, + content=content.replace(']]>', ']]]]>'), ) return response_xml @@ -202,15 +229,7 @@ class OAClient: await asyncio.sleep(interval) - if self.msg_id_map.get(message_data['MsgId'], 1) == 3: - # response_xml = xml_template.format( - # to_user=from_user, - # from_user=to_user, - # create_time=int(time.time()), - # content = "请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。" - # ) - print('请求失效:暂不支持公众号超过15秒的请求,如有需求,请联系 LangBot 团队。') - return '' + return 'success' except Exception: await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') @@ -219,14 +238,20 @@ class OAClient: async def get_message(self, xml_msg: str): root = await asyncio.to_thread(ET.fromstring, xml_msg) - message_data = { - 'ToUserName': root.find('ToUserName').text, - 'FromUserName': root.find('FromUserName').text, - 'CreateTime': int(root.find('CreateTime').text), - 'MsgType': root.find('MsgType').text, - 'Content': root.find('Content').text if root.find('Content') is not None else None, - 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, - } + message_data = {element.tag: element.text for element in root} + message_data['CreateTime'] = int(message_data.get('CreateTime') or 0) + if message_data.get('MsgId'): + message_data['MsgId'] = int(message_data['MsgId']) + else: + message_data['MsgId'] = ':'.join( + str(message_data.get(key) or '') + for key in ( + 'FromUserName', + 'CreateTime', + 'Event', + 'EventKey', + ) + ) return message_data @@ -249,6 +274,12 @@ class OAClient: return decorator + async def _process_message(self, event: OAEvent): + try: + await self._handle_message(event) + except Exception: + await self.logger.error(f'OfficialAccount processing failed: {traceback.format_exc()}') + async def _handle_message(self, event: OAEvent): """ 处理消息事件。 @@ -288,6 +319,7 @@ class OAClientForLongerResponse: logger: None, unified_mode: bool = False, api_base_url: str = 'https://api.weixin.qq.com', + customer_service: bool = False, ): self.token = token self.aes = EncodingAESKey @@ -313,8 +345,13 @@ class OAClientForLongerResponse: } self.access_token_expiry_time = None self.loading_message = LoadingMessage + from .customer_service import CustomerServiceReplies + + self.customer_service = CustomerServiceReplies(AppID, Appsecret, api_base_url) if customer_service else None + self._callback_responses = {} self.msg_queue = {} self.user_msg_queue = {} + self._processing_tasks: set[asyncio.Task] = set() self._last_queue_cleanup = 0.0 self.logger = logger @@ -336,6 +373,10 @@ class OAClientForLongerResponse: def clear(self) -> None: self.msg_queue.clear() self.user_msg_queue.clear() + self._callback_responses.clear() + for task in self._processing_tasks: + task.cancel() + self._processing_tasks.clear() async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)。""" @@ -393,27 +434,26 @@ class OAClientForLongerResponse: return 'signature verification failed', 403 elif req.method == 'POST': - encryt_msg = await req.data - if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES: - raise ValueError('Official Account callback body exceeds the size limit') - wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) - ret, xml_msg = await asyncio.to_thread( - wxcpt.DecryptMsg, - encryt_msg, - msg_signature, - timestamp, - nonce, - ) - xml_msg = xml_msg.decode('utf-8') - - if ret != 0: - await self.logger.error('消息解密失败') - raise Exception('消息解密失败') + xml_msg = await _read_callback_xml(self, req) # 解析 XML root = await asyncio.to_thread(ET.fromstring, xml_msg) from_user = root.find('FromUserName').text to_user = root.find('ToUserName').text + message_key = ( + from_user, + root.findtext('MsgId') + or (root.findtext('CreateTime'), root.findtext('Event'), root.findtext('EventKey')), + ) + cached = self._callback_responses.get(message_key) + if cached and time.monotonic() - cached[0] < 600: + return cached[1] + + def remember(response): + self._callback_responses[message_key] = (time.monotonic(), response) + while len(self._callback_responses) > self._MAX_USERS: + self._callback_responses.pop(next(iter(self._callback_responses))) + return response if self.msg_queue.get(from_user) and self.msg_queue[from_user][0]['content']: queue_top = self.msg_queue[from_user].pop(0) @@ -428,20 +468,22 @@ class OAClientForLongerResponse: to_user=from_user, from_user=to_user, create_time=int(time.time()), - content=queue_content, + content=queue_content.replace(']]>', ']]]]>'), ) - return response_xml + return remember(response_xml) else: response_xml = xml_template.format( to_user=from_user, from_user=to_user, create_time=int(time.time()), - content=self.loading_message, + content=self.loading_message.replace(']]>', ']]]]>'), ) + if not self.loading_message: + response_xml = 'success' - if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user][0]['content']: - return response_xml + if self.user_msg_queue.get(from_user): + return remember(response_xml) else: message_data = await self.get_message(xml_msg) @@ -457,9 +499,15 @@ class OAClientForLongerResponse: -self._MAX_MESSAGES_PER_USER : ] self._prune_queues() - await self._handle_message(event) + # The callback must return the loading reply immediately. + # Native listeners establish their own Workspace scope. + from langbot.pkg.core.task_boundary import create_detached_task - return response_xml + task = create_detached_task(self._process_pending_message(event)) + self._processing_tasks.add(task) + task.add_done_callback(self._processing_tasks.discard) + + return remember(response_xml) except Exception: await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') @@ -468,14 +516,20 @@ class OAClientForLongerResponse: async def get_message(self, xml_msg: str): root = await asyncio.to_thread(ET.fromstring, xml_msg) - message_data = { - 'ToUserName': root.find('ToUserName').text, - 'FromUserName': root.find('FromUserName').text, - 'CreateTime': int(root.find('CreateTime').text), - 'MsgType': root.find('MsgType').text, - 'Content': root.find('Content').text if root.find('Content') is not None else None, - 'MsgId': int(root.find('MsgId').text) if root.find('MsgId') is not None else None, - } + message_data = {element.tag: element.text for element in root} + message_data['CreateTime'] = int(message_data.get('CreateTime') or 0) + if message_data.get('MsgId'): + message_data['MsgId'] = int(message_data['MsgId']) + else: + message_data['MsgId'] = ':'.join( + str(message_data.get(key) or '') + for key in ( + 'FromUserName', + 'CreateTime', + 'Event', + 'EventKey', + ) + ) return message_data @@ -508,7 +562,32 @@ class OAClientForLongerResponse: for handler in self._message_handlers[msg_type]: await handler(event) + async def _process_pending_message(self, event: OAEvent): + try: + await self._handle_message(event) + except asyncio.CancelledError: + raise + except Exception: + self.user_msg_queue.pop(event.user_id, None) + await self.logger.error(f'OfficialAccount passive processing failed: {traceback.format_exc()}') + async def set_message(self, from_user: int, message_id: int, content: str): + if self.customer_service is not None: + try: + code = await self.customer_service.send(str(from_user), content) + except Exception as exc: + # Do not expose request URLs containing access tokens or AppSecret. + self.user_msg_queue.pop(from_user, None) + raise RuntimeError(f'OfficialAccount customer message failed ({type(exc).__name__})') from None + if code == 0: + self.user_msg_queue.pop(from_user, None) + return False + if code not in {48001, 45015, 45047, 43004}: + self.user_msg_queue.pop(from_user, None) + raise RuntimeError(f'OfficialAccount customer message rejected: errcode={code}') + await self.logger.info( + f'OfficialAccount customer message unavailable (errcode={code}); reply queued for next message' + ) if from_user not in self.msg_queue: self.msg_queue[from_user] = [] @@ -520,3 +599,4 @@ class OAClientForLongerResponse: ) self.msg_queue[from_user] = self.msg_queue[from_user][-self._MAX_MESSAGES_PER_USER :] self._prune_queues() + return True diff --git a/src/langbot/libs/official_account_api/customer_service.py b/src/langbot/libs/official_account_api/customer_service.py new file mode 100644 index 000000000..e5fe23f75 --- /dev/null +++ b/src/langbot/libs/official_account_api/customer_service.py @@ -0,0 +1,46 @@ +"""Optional asynchronous replies through WeChat's customer service API.""" + +import asyncio +import time + +import httpx + + +class CustomerServiceReplies: + def __init__(self, appid: str, secret: str, base_url: str): + self.appid = appid + self.secret = secret + self.base_url = base_url.rstrip('/') + self._token = '' + self._expires = 0.0 + self._lock = asyncio.Lock() + + async def send(self, user_id: str, content: str) -> int: + # Retry only explicit token rejection, never an ambiguous network failure. + async with self._lock, httpx.AsyncClient(timeout=20) as client: + for attempt in range(2): + if time.monotonic() >= self._expires: + response = await client.get( + f'{self.base_url}/cgi-bin/token', + params={'grant_type': 'client_credential', 'appid': self.appid, 'secret': self.secret}, + ) + if response.status_code != 200: + raise RuntimeError(f'OfficialAccount token request HTTP {response.status_code}') + data = response.json() + if data.get('errcode'): + return int(data['errcode']) + self._token = data['access_token'] + self._expires = time.monotonic() + max(0, int(data['expires_in']) - 120) + response = await client.post( + f'{self.base_url}/cgi-bin/message/custom/send', + params={'access_token': self._token}, + json={'touser': user_id, 'msgtype': 'text', 'text': {'content': content}}, + ) + if response.status_code != 200: + raise RuntimeError(f'OfficialAccount customer message HTTP {response.status_code}') + code = int(response.json()['errcode']) + if code in {40001, 40014, 42001} and attempt == 0: + self._expires = 0.0 + continue + return code + raise RuntimeError('OfficialAccount token refresh failed') diff --git a/src/langbot/libs/openclaw_weixin_api/typing_indicator.py b/src/langbot/libs/openclaw_weixin_api/typing_indicator.py new file mode 100644 index 000000000..9334fde0d --- /dev/null +++ b/src/langbot/libs/openclaw_weixin_api/typing_indicator.py @@ -0,0 +1,120 @@ +"""Best-effort, per-peer typing leases for the iLink transport.""" + +import asyncio +import contextlib +import logging +import time +from collections import OrderedDict +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class Lease: + users: int = 0 + task: asyncio.Task | None = None + ticket: str = '' + + +class TypingIndicator: + INTERVAL = 5.0 + TIMEOUT = 3.0 + TICKET_TTL = 600.0 + MAX_PEERS = 4096 + + def __init__(self, client, context_token): + self.client = client + self.context_token = context_token + self.leases: dict[str, Lease] = {} + self.cache = OrderedDict() + self.closed = False + + async def _ticket(self, peer): + now = time.monotonic() + cached = self.cache.get(peer) + if cached and cached[1] > now: + return cached[0] + try: + response = await asyncio.wait_for( + self.client.get_config(peer, self.context_token(peer)), + self.TIMEOUT, + ) + if response.ret not in (None, 0): + raise ValueError('getconfig failed') + ticket = response.typing_ticket or '' + delay = self.TICKET_TTL if ticket else 30.0 + except Exception: + ticket = '' + delay = min((cached[2] * 2 if cached else 5.0), 300.0) + logger.debug('Weixin typing ticket unavailable; retry deferred') + self.cache[peer] = (ticket, now + delay, delay) + self.cache.move_to_end(peer) + while len(self.cache) > self.MAX_PEERS: + self.cache.popitem(last=False) + return ticket + + async def _run(self, peer, lease): + try: + while not self.closed and lease.users: + ticket = await self._ticket(peer) + if ticket: + # Keep the last ticket so cancellation can stop an in-flight start. + lease.ticket = ticket + try: + await asyncio.wait_for(self.client.send_typing(peer, ticket), self.TIMEOUT) + except Exception: + self.cache[peer] = ('', time.monotonic() + 30, 30) + logger.debug('Weixin typing start failed; retry deferred') + await asyncio.sleep(self.INTERVAL) + finally: + if lease.ticket: + try: + # Refresh expired tickets before cancelling a long-running turn. + ticket = await self._ticket(peer) or lease.ticket + await asyncio.wait_for(self.client.stop_typing(peer, ticket), self.TIMEOUT) + except Exception: + logger.debug('Weixin typing stop failed') + + @contextlib.asynccontextmanager + async def processing(self, peer): + lease = None + while not self.closed: + lease = self.leases.get(peer) + if lease is None: + if len(self.leases) >= self.MAX_PEERS: + lease = None + break + lease = Lease(users=1) + self.leases[peer] = lease + lease.task = asyncio.create_task(self._run(peer, lease)) + break + if lease.users: + lease.users += 1 + break + # Serialize stop/start for the same peer: an old stop must not cancel a new start. + await asyncio.gather(asyncio.shield(lease.task), return_exceptions=True) + if self.leases.get(peer) is lease: + self.leases.pop(peer, None) + lease = None + try: + yield + finally: + if lease is not None and lease.users: + lease.users -= 1 + if not lease.users: + if not lease.task.cancelling(): + lease.task.cancel() + await asyncio.gather(asyncio.shield(lease.task), return_exceptions=True) + if self.leases.get(peer) is lease: + self.leases.pop(peer, None) + + async def close(self): + self.closed = True + tasks = [lease.task for lease in self.leases.values()] + for task in tasks: + if not task.done() and not task.cancelling(): + task.cancel() + await asyncio.gather(*(asyncio.shield(task) for task in tasks), return_exceptions=True) + self.leases.clear() + self.cache.clear() diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py index 69d70b402..d96422a80 100644 --- a/src/langbot/libs/slack_api/api.py +++ b/src/langbot/libs/slack_api/api.py @@ -1,10 +1,13 @@ import asyncio import json import traceback +from urllib.parse import parse_qs +from slack_sdk.signature import SignatureVerifier from quart import Quart, jsonify, request from slack_sdk.web.async_client import AsyncWebClient from .slackevent import SlackEvent from typing import Callable +from collections import OrderedDict import langbot_plugin.api.entities.builtin.platform.events as platform_events _MAX_CALLBACK_BODY_BYTES = 1024 * 1024 @@ -30,6 +33,11 @@ class SlackClient: } self.bot_user_id = None # 避免机器人回复自己的消息 self.logger = logger + self.socket_client = None + self.socket_queue = asyncio.Queue(maxsize=100) + self.socket_seen = OrderedDict() + self.socket_stop = asyncio.Event() + self.socket_worker = None async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)""" @@ -44,6 +52,8 @@ class SlackClient: Returns: 响应数据 """ + if self.socket_client is not None: + return jsonify({'error': 'Socket Mode is enabled'}), 409 return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): @@ -56,40 +66,122 @@ class SlackClient: body = await req.get_data() if len(body) > _MAX_CALLBACK_BODY_BYTES: raise ValueError('Slack callback body exceeds the size limit') - data = await asyncio.to_thread(json.loads, body) + if not SignatureVerifier(self.signing_secret).is_valid_request(body, dict(req.headers)): + return jsonify({'error': 'Invalid Slack signature'}), 401 + if req.mimetype == 'application/x-www-form-urlencoded': + data = json.loads(parse_qs(body.decode()).get('payload', ['{}'])[0]) + else: + data = json.loads(body) if 'type' in data: if data['type'] == 'url_verification': return data['challenge'] - bot_user_id = data.get('event', {}).get('bot_id', '') - - if self.bot_user_id and bot_user_id == self.bot_user_id: - return jsonify({'status': 'ok'}) - - # 处理私信 - if data and data.get('event', {}).get('channel_type') in ['im']: - event = SlackEvent.from_payload(data) - await self._handle_message(event) - return jsonify({'status': 'ok'}) - - # 处理群聊 - if data.get('event', {}).get('type') == 'app_mention': - data.setdefault('event', {})['channel_type'] = 'channel' - event = SlackEvent.from_payload(data) - await self._handle_message(event) - return jsonify({'status': 'ok'}) - - return jsonify({'status': 'ok'}) + self._start_worker() + if not self._enqueue(data, data.get('event_id')): + return jsonify({'error': 'Slack event queue is full'}), 503 + return '', 200 except Exception as e: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') raise (e) - async def _handle_message(self, event: SlackEvent): + async def handle_payload(self, data): + """Dispatch the same event representation for HTTP and Socket Mode.""" + raw = data.get('event', {}) + for authorization in data.get('authorizations', []): + if not self.bot_user_id and authorization.get('is_bot') and authorization.get('user_id'): + self.bot_user_id = authorization['user_id'] + break + if data.get('type') in {'block_actions', 'view_submission', 'view_closed', 'shortcut', 'message_action'}: + await self._handle_message(SlackEvent(data), 'event') + return + if raw.get('bot_id') or raw.get('subtype') == 'bot_message': + return + kind, subtype = raw.get('type'), raw.get('subtype') + if kind == 'message' and subtype not in (None, 'file_share', 'thread_broadcast'): + await self._handle_message(SlackEvent(data), 'event') + elif raw.get('channel_type') == 'im' and kind == 'message': + await self._handle_message(SlackEvent.from_payload(data)) + elif kind == 'app_mention': + raw['channel_type'] = 'channel' + await self._handle_message(SlackEvent.from_payload(data)) + elif kind != 'message': + await self._handle_message(SlackEvent(data), 'event') + + def _enqueue(self, payload, key=None): + if key and key in self.socket_seen: + return True + try: + self.socket_queue.put_nowait(payload) + except asyncio.QueueFull: + return False + if key: + self.socket_seen[key] = True + while len(self.socket_seen) > 4096: + self.socket_seen.popitem(last=False) + return True + + def _start_worker(self): + if self.socket_worker is not None and not self.socket_worker.done(): + return + + async def worker(): + while True: + payload = await self.socket_queue.get() + try: + await self.handle_payload(payload) + except Exception: + await self.logger.error('Slack event processing failed') + finally: + self.socket_queue.task_done() + + self.socket_worker = asyncio.create_task(worker()) + + async def _socket_request(self, client, req): + from slack_sdk.socket_mode.response import SocketModeResponse + + if req.type in {'events_api', 'interactive'}: + key = req.payload.get('event_id') or req.envelope_id + if not self._enqueue(req.payload, key): + # Leave unacknowledged so Slack can retry; do not grow tasks without bounds. + await self.logger.warning('Slack Socket Mode event queue is full') + return + await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) + + async def run_socket(self, app_token): + from slack_sdk.socket_mode.aiohttp import SocketModeClient + + self.socket_stop.clear() + self.socket_client = SocketModeClient(app_token=app_token, web_client=self.client) + self.socket_client.socket_mode_request_listeners.append(self._socket_request) + + self._start_worker() + try: + await self.socket_client.connect() + await self.logger.info('Slack Socket Mode connected') + await self.socket_stop.wait() + finally: + await self.close_socket() + + async def close_socket(self): + self.socket_stop.set() + client, self.socket_client = self.socket_client, None + if client is not None: + await client.close() + worker, self.socket_worker = self.socket_worker, None + if worker is not None: + worker.cancel() + await asyncio.gather(worker, return_exceptions=True) + self.socket_seen.clear() + while not self.socket_queue.empty(): + self.socket_queue.get_nowait() + self.socket_queue.task_done() + + async def _handle_message(self, event: SlackEvent, kind=None): """ 处理消息事件。 """ - msg_type = event.type + msg_type = kind or event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) @@ -109,8 +201,8 @@ class SlackClient: try: response = await self.client.chat_postMessage(channel=channel_id, text=text) if self.bot_user_id is None and response.get('ok'): - self.bot_user_id = response['message']['bot_id'] - return + self.bot_user_id = response.get('message', {}).get('user') + return response.data if hasattr(response, 'data') else response except Exception as e: await self.logger.error(f'Error in send_message: {e}') raise e @@ -119,9 +211,9 @@ class SlackClient: try: response = await self.client.chat_postMessage(channel='@' + user_id, text=text) if self.bot_user_id is None and response.get('ok'): - self.bot_user_id = response['message']['bot_id'] + self.bot_user_id = response.get('message', {}).get('user') - return + return response.data if hasattr(response, 'data') else response except Exception as e: await self.logger.error(f'Error in send_message: {traceback.format_exc()}') raise e diff --git a/src/langbot/libs/slack_api/media.py b/src/langbot/libs/slack_api/media.py new file mode 100644 index 000000000..f220af14f --- /dev/null +++ b/src/langbot/libs/slack_api/media.py @@ -0,0 +1,125 @@ +"""Transfer attachment bytes rather than degrading attachments to text.""" + +import asyncio +import mimetypes +from pathlib import Path +from urllib.parse import urlparse, unquote + +import aiohttp + +from langbot.pkg.utils import httpclient, image +from langbot_plugin.api.entities.builtin.platform import message as pm + +MAX_MEDIA_BYTES = 20 * 1024 * 1024 +MEDIA_TYPES = (pm.Image, pm.File, pm.Voice) + + +async def download_media(url: str, token: str = '') -> tuple[bytes, str]: + parsed = urlparse(url) + if parsed.scheme not in {'http', 'https'}: + raise ValueError('Slack media requires an HTTP(S) URL') + # Never disclose a bot token to arbitrary media hosts or redirects. + private = parsed.scheme == 'https' and parsed.hostname == 'files.slack.com' + headers = {'Authorization': f'Bearer {token}'} if private and token else {} + async with httpclient.get_session().get( + url, headers=headers, allow_redirects=not bool(headers), timeout=aiohttp.ClientTimeout(total=30) + ) as response: + if response.status != 200: + raise ValueError(f'Slack media download failed (HTTP {response.status})') + data = await httpclient.read_limited(response, max_bytes=MAX_MEDIA_BYTES) + return data, response.headers.get('Content-Type', 'application/octet-stream').split(';')[0] + + +async def attachment_bytes(component, token: str = '') -> tuple[bytes, str]: + name = getattr(component, 'name', '') + mime = '' + if component.base64: + value = component.base64 + if value.startswith('data:'): + header, value = value.split(',', 1) + mime = header[5:].split(';')[0] + data = await image.decode_base64_limited(value, max_bytes=MAX_MEDIA_BYTES) + elif component.path: + path = Path(component.path) + + def read(): + with path.open('rb') as stream: + return stream.read(MAX_MEDIA_BYTES + 1) + + data = await asyncio.to_thread(read) + name = name or path.name + elif component.url: + data, mime = await download_media(component.url, token) + name = name or Path(unquote(urlparse(component.url).path)).name + else: + raise ValueError('Slack attachment has no base64, path or URL') + if not data or len(data) > MAX_MEDIA_BYTES: + raise ValueError('Slack attachment is empty or exceeds the 20 MiB limit') + if not name: + if isinstance(component, pm.Image) and not mime: + mime = await asyncio.to_thread(_image_mime, data) + name = 'attachment' + (mimetypes.guess_extension(mime) or '.bin') + return data, name.replace('\\', '/').rsplit('/', 1)[-1] + + +def _image_mime(data: bytes) -> str: + import io + from PIL import Image + + with Image.open(io.BytesIO(data)) as picture: + return Image.MIME.get(picture.format, 'application/octet-stream') + + +async def send_chain( + bot, target_type: str, target_id: str, chain: pm.MessageChain, thread_ts: str | None = None +) -> dict: + from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter + + if target_type not in {'person', 'channel'}: + raise ValueError(f'Unsupported Slack target type: {target_type}') + responses = [] + pending = [] + channel = None + + async def flush(): + if not pending: + return + text = await SlackMessageConverter.yiri2target(pm.MessageChain(list(pending))) + pending.clear() + if text: + if thread_ts: + response = await bot.client.chat_postMessage(channel=target_id, text=text, thread_ts=thread_ts) + responses.append(response.data if hasattr(response, 'data') else response) + else: + method = bot.send_message_to_one if target_type == 'person' else bot.send_message_to_channel + responses.append(await method(text, target_id)) + + for component in chain: + if isinstance(component, MEDIA_TYPES): + data, filename = await attachment_bytes(component, bot.bot_token) + await flush() + if channel is None: + if target_type == 'person' and not target_id.startswith('D'): + opened = await bot.client.conversations_open(users=[target_id]) + channel = opened['channel']['id'] + else: + channel = target_id + response = await bot.client.files_upload_v2( + channel=channel, + file=data, + filename=filename, + title=filename, + **({'thread_ts': thread_ts} if thread_ts else {}), + ) + if not response.get('ok'): + raise RuntimeError(f'Slack file upload failed: {response.get("error", "unknown_error")}') + responses.append(response.data if hasattr(response, 'data') else response) + elif isinstance(component, pm.Forward): + await flush() + for node in component.node_list: + if node.message_chain: + responses.append(await send_chain(bot, target_type, target_id, node.message_chain, thread_ts)) + else: + pending.append(component) + await flush() + return {'target_type': target_type, 'target_id': target_id, 'responses': responses} diff --git a/src/langbot/libs/slack_api/slackevent.py b/src/langbot/libs/slack_api/slackevent.py index 77177816e..0834d212b 100644 --- a/src/langbot/libs/slack_api/slackevent.py +++ b/src/langbot/libs/slack_api/slackevent.py @@ -12,6 +12,8 @@ class SlackEvent(dict): @property def text(self) -> str: + if self.get('event', {}).get('text') is not None: + return self['event']['text'] if self.get('event', {}).get('channel_type') == 'im': blocks = self.get('event', {}).get('blocks', []) if not blocks: @@ -63,7 +65,8 @@ class SlackEvent(dict): @property def message_id(self) -> str: - return self.get('event_id', '') + raw = self.get('event', {}) + return raw.get('deleted_ts') or raw.get('message', {}).get('ts') or raw.get('ts', '') @property def pic_url(self) -> str: diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 785e82f28..207315d28 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -1658,6 +1658,8 @@ class WecomBotClient: self._card_action_callback: Optional[Callable] = None self._stream_last_content: dict[str, str] = {} self._dispatch_tasks: set[asyncio.Task] = set() + self._welcome_replies: dict[str, asyncio.Future] = {} + self._handled_form_tasks: dict[str, float] = {} # Optional `source` block injected into every interactive template_card # the client builds. Set via `set_card_source` from the adapter after # reading config. Format: {icon_url, desc, desc_color}. @@ -1764,7 +1766,12 @@ class WecomBotClient: event: 由企业微信消息转换的内部事件对象。 """ try: - await self._handle_message(event) + if event.msgtype == 'event': + kind = 'template_card_event' if extract_wecom_event_type(event) == 'template_card_event' else 'event' + for handler in self._message_handlers.get(kind, []): + await handler(event) + else: + await self._handle_message(event) except Exception: await self.logger.error(traceback.format_exc()) @@ -1996,11 +2003,48 @@ class WecomBotClient: if event_type == 'template_card_event': return await self._handle_template_card_event(msg_json, nonce) + if event_type == 'enter_chat': + return await self._handle_enter_chat(msg_json, nonce) + + if msg_json.get('msgtype') == 'event': + event = wecombotevent.WecomBotEvent({**msg_json, 'type': msg_json.get('chattype', 'single')}) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) + return await self._encrypt_and_reply({}, nonce) + if msg_json.get('msgtype') == 'stream': return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce) + async def reply_welcome(self, message_id: str, content: str) -> dict: + """Complete the pending HTTP welcome response before its deadline.""" + future = self._welcome_replies.get(message_id) + if future is None or future.done(): + raise ValueError('WeCom welcome reply window has expired or was already used') + future.set_result({'msgtype': 'text', 'text': {'content': content}}) + return {'ok': True} + + async def _handle_enter_chat(self, msg_json: dict[str, Any], nonce: str): + message_id = str(msg_json.get('msgid') or '') + if not message_id or message_id in self._welcome_replies: + return await self._encrypt_and_reply({}, nonce) + future = asyncio.get_running_loop().create_future() + self._welcome_replies[message_id] = future + try: + event = wecombotevent.WecomBotEvent({**msg_json, 'type': msg_json.get('chattype', 'single')}) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) + try: + reply = await asyncio.wait_for(asyncio.shield(future), timeout=4.5) + except asyncio.TimeoutError: + reply = {} + return await self._encrypt_and_reply(reply, nonce) + finally: + self._welcome_replies.pop(message_id, None) + if not future.done(): + future.cancel() + async def _handle_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """Handle a button click on a button_interaction template_card. @@ -2014,10 +2058,29 @@ class WecomBotClient: await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') + now = time.monotonic() + self._handled_form_tasks = { + key: seen for key, seen in self._handled_form_tasks.items() if now - seen < 3600 + } + if task_id in self._handled_form_tasks: + return await self._encrypt_and_reply({}, nonce) session = self.stream_sessions.get_session_by_task_id(task_id) if session is None: - await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃') + if not task_id.startswith('dify-'): + event = wecombotevent.WecomBotEvent( + { + **msg_json, + 'type': msg_json.get('chattype', 'single'), + 'msgtype': 'event', + 'eventtype': 'template_card_event', + } + ) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) else: + self._handled_form_tasks[task_id] = now + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) if self._card_action_callback is not None: try: await self._card_action_callback(session, event_key, task_id, msg_json) @@ -2123,7 +2186,7 @@ class WecomBotClient: Args: msg_id: 原始企业微信消息 ID。 - content: 模型产生的片段内容。 + content: 流水线传入的累计正文快照。 is_final: 是否为最终片段。 Returns: @@ -2138,12 +2201,8 @@ class WecomBotClient: return False previous_content = self._stream_last_content.get(msg_id, '') - if previous_content and content.startswith(previous_content): - next_content = content - elif previous_content and not content: - next_content = previous_content - else: - next_content = previous_content + content if previous_content else content + # Pipeline delivery provides a snapshot, not a provider delta. + next_content = content or previous_content if len(next_content) > _MAX_STREAM_CONTENT_CHARS: next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:] diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 3f4cb0504..9daf15900 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -11,6 +11,7 @@ Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk from __future__ import annotations import asyncio +import hashlib import json import secrets import time @@ -134,6 +135,9 @@ class WecomBotWsClient: self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id # Dedup: skip sending when content hasn't changed self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent + # Preserve completion identity after removing active stream mappings. + # A runner may emit both a final delta and a completed message snapshot. + self._completed_stream_content: dict[str, str] = {} # Stream session info for feedback tracking self._stream_sessions: dict[str, dict] = {} # msg_id -> session info # Feedback tracking: feedback_id -> session info @@ -146,6 +150,7 @@ class WecomBotWsClient: # context on click). # task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id} self._pending_forms_by_task: dict[str, dict] = {} + self._handled_form_tasks: dict[str, float] = {} # Reverse: msg_id -> task_id (for cleanup when stream finishes). self._task_id_by_msg: dict[str, str] = {} # Optional card-action callback registered by the adapter. @@ -320,6 +325,12 @@ class WecomBotWsClient: } return await self._send_reply(req_id, body) + async def reply_welcome(self, req_id: str, content: str) -> Optional[dict]: + """Reply to enter_chat using the dedicated welcome command.""" + return await self._send_reply( + req_id, {'msgtype': 'text', 'text': {'content': content}}, cmd=CMD_RESPOND_WELCOME + ) + async def reply_text(self, req_id: str, content: str) -> Optional[dict]: """Send a non-streaming text reply. @@ -661,16 +672,15 @@ class WecomBotWsClient: """ key = self._stream_ids.get(msg_id) if not key: + if is_final and self._completed_stream_content.get(msg_id) == hashlib.sha256(content.encode()).hexdigest(): + return True return False req_id, stream_id = key.split('|', 1) try: previous_content = self._stream_last_content.get(msg_id, '') - if previous_content and content.startswith(previous_content): - next_content = content - elif previous_content and not content: - next_content = previous_content - else: - next_content = previous_content + content if previous_content else content + # Pipeline delivery provides complete snapshots, including edits + # that need not start with the previous text (e.g. reasoning tags). + next_content = content or previous_content if len(next_content) > _MAX_STREAM_CONTENT_CHARS: next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:] @@ -720,6 +730,8 @@ class WecomBotWsClient: await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id) self._stream_last_content[msg_id] = next_content if is_final: + self._completed_stream_content[msg_id] = hashlib.sha256(next_content.encode()).hexdigest() + self._cap_mapping(self._completed_stream_content, _STREAM_CACHE_MAX) self._stream_ids.pop(msg_id, None) self._stream_last_content.pop(msg_id, None) self._stream_sessions.pop(msg_id, None) @@ -981,6 +993,7 @@ class WecomBotWsClient: await self.logger.debug(f'Received event_callback event_type={event_type}') message_data = { + **body, 'msgtype': 'event', 'type': body.get('chattype', 'single'), 'event': event_info, @@ -1064,15 +1077,35 @@ class WecomBotWsClient: f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' ) + now = time.monotonic() + self._handled_form_tasks = {key: seen for key, seen in self._handled_form_tasks.items() if now - seen < 3600} + if task_id in self._handled_form_tasks: + return pending = self._pending_forms_by_task.get(task_id) if pending is None: - await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored') + # Expired managed forms must not start a new business execution. + if task_id.startswith('dify-'): + return + event = wecombotevent.WecomBotEvent( + { + **body, + 'type': body.get('chattype', 'single'), + 'msgtype': 'event', + 'eventtype': 'template_card_event', + 'req_id': frame.get('headers', {}).get('req_id', ''), + } + ) + for handler in self._message_handlers.get('template_card_event', []): + await handler(event) return if time.monotonic() - float(pending.get('created_at', 0.0)) > _PENDING_FORM_TTL_SECONDS: self._drop_pending_form_task(task_id, pending) await self.logger.warning(f'Pending form expired for task_id={task_id} (ws)') return + self._handled_form_tasks[task_id] = now + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) req_id_for_update = frame.get('headers', {}).get('req_id', '') form_data = pending.get('form_data', {}) or {} selections = extract_template_card_selections(tce, form_data) @@ -1120,6 +1153,9 @@ class WecomBotWsClient: self._drop_pending_form_task(task_id, pending) def _drop_pending_form_task(self, task_id: str, pending: dict) -> None: + self._handled_form_tasks[task_id] = time.monotonic() + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) self._pending_forms_by_task.pop(task_id, None) msg_id = pending.get('msg_id', '') if msg_id: diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index da3caa9fd..916cd3f39 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/libs/wecom_customer_service_api/api.py @@ -5,6 +5,7 @@ import base64 import binascii import contextvars import functools +import hashlib import httpx import json import os @@ -119,6 +120,10 @@ class WecomCSClient: 'example': [], } self._http_client: httpx.AsyncClient | None = None + self._delivery_tasks: set[asyncio.Task] = set() + self._received_messages: dict[str, float] = {} + self._sync_lock = asyncio.Lock() + self._sync_cursors: dict[str, str] = {} @asynccontextmanager async def _http_client_context(self): @@ -127,6 +132,10 @@ class WecomCSClient: yield self._http_client async def close(self) -> None: + for task in self._delivery_tasks: + task.cancel() + if self._delivery_tasks: + await asyncio.gather(*self._delivery_tasks, return_exceptions=True) if self._http_client is not None: await self._http_client.aclose() self._http_client = None @@ -170,47 +179,46 @@ class WecomCSClient: else: raise Exception(f'未获取access token: {data}') - @_bounded_token_retry async def get_detailed_message_list(self, xml_msg: str): - # 在本方法中解析消息,并且获得消息的具体内容 - if isinstance(xml_msg, bytes): - xml_msg = xml_msg.decode('utf-8') + """Drain all callback pages, including non-message events and empty pages.""" root = await asyncio.to_thread(ET.fromstring, xml_msg) - token = root.find('Token').text - open_kfid = root.find('OpenKfId').text - - # if open_kfid in self.openkfid_list: - # return None - # else: - # self.openkfid_list.append(open_kfid) - - if not await self.check_access_token(): - self.access_token = await self.get_access_token(self.secret) - - url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token - async with self._http_client_context() as client: - params = { - 'token': token, - 'voice_format': 0, - 'open_kfid': open_kfid, - } - response = await client.post(url, json=params) - data = await httpclient.parse_json_response(response) - if data['errcode'] == 40014 or data['errcode'] == 42001: - self.access_token = await self.get_access_token(self.secret) - return await self.get_detailed_message_list(xml_msg) - if data['errcode'] != 0: - raise Exception('Failed to get message') - - last_msg_data = data['msg_list'][-1] - open_kfid = last_msg_data.get('open_kfid') - # 进行获取图片操作 - if last_msg_data.get('msgtype') == 'image': - media_id = last_msg_data.get('image').get('media_id') - picurl = await self.get_pic_url(media_id) - last_msg_data['picurl'] = picurl - # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) - return last_msg_data + token = root.findtext('Token') + open_kfid = root.findtext('OpenKfId') + if not token or not open_kfid: + raise ValueError('WeCom customer-service callback is missing Token or OpenKfId') + async with self._sync_lock: + cursor = self._sync_cursors.get(open_kfid) + messages = [] + refreshed = False + while True: + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + params = {'token': token, 'voice_format': 0, 'open_kfid': open_kfid, 'limit': 1000} + if cursor: + params['cursor'] = cursor + async with self._http_client_context() as client: + response = await client.post( + self.base_url + '/kf/sync_msg', + params={'access_token': self.access_token}, + json=params, + ) + data = await httpclient.parse_json_response(response) + if data.get('errcode') in (40014, 42001) and not refreshed: + self.access_token = await self.get_access_token(self.secret) + refreshed = True + continue + if data.get('errcode') != 0: + raise RuntimeError(f'WeCom sync_msg failed (errcode={data.get("errcode")})') + messages.extend(data.get('msg_list') or []) + next_cursor = data.get('next_cursor') + if data.get('has_more'): + if not next_cursor or next_cursor == cursor: + raise RuntimeError('WeCom sync_msg returned a non-advancing cursor') + cursor = next_cursor + continue + if next_cursor: + self._sync_cursors[open_kfid] = next_cursor + return messages @_bounded_token_retry async def change_service_status(self, userid: str, openkfid: str, servicer: str): @@ -292,9 +300,36 @@ class WecomCSClient: return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: await self.logger.error(f'发送消息失败:{data}') + if data['errcode'] == 95002: + raise ValueError( + 'WeCom CS rejected the reply (95002): the customer must send a message first, ' + 'and ordinary replies must be sent within 48 hours. Entering a session alone ' + 'does not open this window; welcome replies require the event reply API.' + ) raise Exception(f'Failed to send message: {data}') return data + @_bounded_token_retry + async def send_event_text(self, code: str, msgid: str, content: str): + """Send a single event response using its short-lived, one-time code.""" + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = f'{self.base_url}/kf/send_msg_on_event?access_token={self.access_token}' + payload = {'code': code, 'msgid': msgid, 'msgtype': 'text', 'text': {'content': content}} + async with self._http_client_context() as client: + response = await client.post(url, json=payload) + data = await httpclient.parse_json_response(response) + if data.get('errcode') in (40014, 42001): + self.access_token = await self.get_access_token(self.secret) + return await self.send_event_text(code, msgid, content) + if data.get('errcode') != 0: + raise ValueError( + f'WeCom CS event reply failed (errcode={data.get("errcode")}): {data.get("errmsg")}. ' + 'The event code is single-use and must match the current session state; ' + 'welcome and session-end codes expire after 20 seconds. Do not retry an expired or used code.' + ) + return data + @_bounded_token_retry async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str): if not await self.check_access_token(): @@ -375,12 +410,11 @@ class WecomCSClient: if ret != 0: raise Exception(f'消息解密失败,错误码: {ret}') - # 解析消息并处理 - message_data = await self.get_detailed_message_list(xml_msg) - if message_data is not None: - event = WecomCSEvent.from_payload(message_data) - if event: - await self._handle_message(event) + # Acknowledge after verification; API fetching and processing must + # survive a callback disconnect. + task = asyncio.create_task(self._process_callback(xml_msg)) + self._delivery_tasks.add(task) + task.add_done_callback(self._delivery_tasks.discard) return 'success' except Exception as e: @@ -390,6 +424,49 @@ class WecomCSClient: traceback.print_exc() return f'Error processing request: {str(e)}', 400 + async def _process_callback(self, xml_msg): + try: + messages = await self.get_detailed_message_list(xml_msg) + for message_data in messages: + try: + await self._process_synced_message(message_data) + except Exception: + if self.logger: + await self.logger.error(f'WeCom customer-service event failed: {traceback.format_exc()}') + + except Exception: + if self.logger: + await self.logger.error(f'WeCom customer-service delivery failed: {traceback.format_exc()}') + + async def _process_synced_message(self, message_data): + # Agent/API and human replies must not become new customer input. + if message_data.get('msgtype') != 'event' and message_data.get('origin', 3) != 3: + return + if message_data.get('msgtype') == 'image': + media_id = (message_data.get('image') or {}).get('media_id') + if media_id: + message_data['picurl'] = await self.get_pic_url(media_id) + event = WecomCSEvent.from_payload(message_data) + if event: + # A callback disconnect must not cancel processor execution. + # Retries can return the same message from sync_msg. + now = time.monotonic() + self._received_messages = { + key: seen for key, seen in self._received_messages.items() if now - seen < 4 * 86400 + } + message_id = ( + message_data.get('msgid') + or hashlib.sha256( + json.dumps(message_data, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode() + ).hexdigest() + ) + if not message_id or message_id not in self._received_messages: + if message_id: + if len(self._received_messages) >= 4096: + self._received_messages.pop(next(iter(self._received_messages))) + self._received_messages[message_id] = now + await self._handle_message(event) + async def run_task(self, host: str, port: int, *args, **kwargs): """ 启动 Quart 应用。 diff --git a/src/langbot/pkg/agent/runner/config_schema.py b/src/langbot/pkg/agent/runner/config_schema.py index aa7d13f7e..1fad3193f 100644 --- a/src/langbot/pkg/agent/runner/config_schema.py +++ b/src/langbot/pkg/agent/runner/config_schema.py @@ -16,6 +16,65 @@ KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'} PROMPT_EDITOR_TYPES = {'prompt-editor'} NONE_SENTINELS = {'', '__none__', '__none'} +# Selector field types that pick a Host resource. The Runner config form is +# plugin-declared and open-ended (DynamicFormItemSchema allows extra types), so +# this is a registry, not a fixed list: a new selector type is one line here and +# is then reported by telemetry without touching the extraction call sites. +RESOURCE_SELECTOR_TYPES: dict[str, str] = { + 'knowledge-base-multi-selector': 'knowledge_bases', + 'tool-multi-selector': 'tools', + 'mcp-server-multi-selector': 'mcp_servers', + 'skill-multi-selector': 'skills', +} + + +def declared_resource_selectors(descriptor: RunnerDescriptor | None) -> dict[str, list[str]]: + """Map each selected resource kind to the schema field names that select it. + + Only the descriptor is read, so this reports what a Runner *can* select even + when the current configuration leaves the field empty. + """ + declared: dict[str, list[str]] = {} + if descriptor is None: + return declared + for item in descriptor.config_schema or []: + if not isinstance(item, dict): + continue + kind = RESOURCE_SELECTOR_TYPES.get(normalize_schema_item_type(item.get('type'))) + field_name = item.get('name') + if not kind or not isinstance(field_name, str) or not field_name: + continue + declared.setdefault(kind, []) + if field_name not in declared[kind]: + declared[kind].append(field_name) + return declared + + +def extract_selected_resources( + descriptor: RunnerDescriptor | None, + runner_config: dict[str, typing.Any] | None, +) -> dict[str, list[str]]: + """Extract the resource ids selected through schema-declared selector fields. + + Field names are never hard-coded: they come from the Runner's own config + schema, so a form that renames or adds a selector keeps working. + """ + selected: dict[str, list[str]] = {} + if not isinstance(runner_config, dict): + return selected + for kind, field_names in declared_resource_selectors(descriptor).items(): + values: list[str] = [] + for field_name in field_names: + value = runner_config.get(field_name) + if isinstance(value, str): + if value not in NONE_SENTINELS: + values.append(value) + elif isinstance(value, (list, tuple)): + values.extend(item for item in value if isinstance(item, str) and item not in NONE_SENTINELS) + if values: + selected[kind] = list(dict.fromkeys(values)) + return selected + def normalize_schema_item_type(item_type: typing.Any) -> typing.Any: """Normalize legacy/frontend DynamicForm aliases to protocol field types.""" diff --git a/src/langbot/pkg/agent/runner/context_reset.py b/src/langbot/pkg/agent/runner/context_reset.py new file mode 100644 index 000000000..1ee3b2d1c --- /dev/null +++ b/src/langbot/pkg/agent/runner/context_reset.py @@ -0,0 +1,74 @@ +"""Scoped context reset markers, independent of retained monitoring history.""" + +import hashlib +import json +import datetime +import uuid + +import sqlalchemy as sa + +from ...entity.persistence.runner_state import RunnerState + +HOST_RESET_RUNNER = '__langbot_context_reset__' + + +def reset_scope(workspace, bot, conversation): + return 'host-reset:' + hashlib.sha256(json.dumps([workspace, bot, conversation]).encode()).hexdigest() + + +async def reset_runner_context(persistence, workspace, bot, conversation): + """Reset scoped Runner state and history visibility within the caller's transaction.""" + await persistence.execute_async( + sa.delete(RunnerState).where( + RunnerState.workspace_id == workspace, + RunnerState.bot_id == bot, + RunnerState.conversation_id == conversation, + RunnerState.scope == 'conversation', + ) + ) + await persistence.execute_async( + sa.insert(RunnerState).values( + runner_id=HOST_RESET_RUNNER, + binding_identity=HOST_RESET_RUNNER, + scope='conversation', + scope_key=reset_scope(workspace, bot, conversation), + state_key='generation', + value_json=json.dumps(str(uuid.uuid4())), + workspace_id=workspace, + bot_id=bot, + conversation_id=conversation, + created_at=datetime.datetime.utcnow(), + ) + ) + + +async def get_reset_generation(engine, workspace, bot, conversation): + if not all((workspace, bot, conversation)): + return None + async with engine.connect() as connection: + value = ( + await connection.execute( + sa.select(RunnerState.value_json).where( + RunnerState.scope_key == reset_scope(workspace, bot, conversation), + RunnerState.state_key == 'generation', + RunnerState.runner_id == HOST_RESET_RUNNER, + ) + ) + ).scalar_one_or_none() + return json.loads(value) if value else None + + +def active_history_condition(transcript): + cutoff = ( + sa.select(sa.func.max(RunnerState.created_at)) + .where( + RunnerState.runner_id == HOST_RESET_RUNNER, + RunnerState.state_key == 'generation', + RunnerState.workspace_id == transcript.workspace_id, + RunnerState.bot_id == transcript.bot_id, + RunnerState.conversation_id == transcript.conversation_id, + ) + .correlate(transcript) + .scalar_subquery() + ) + return sa.or_(cutoff.is_(None), transcript.created_at > cutoff) diff --git a/src/langbot/pkg/agent/runner/event_log_store.py b/src/langbot/pkg/agent/runner/event_log_store.py index 1dbd8a8b2..10b3a1860 100644 --- a/src/langbot/pkg/agent/runner/event_log_store.py +++ b/src/langbot/pkg/agent/runner/event_log_store.py @@ -128,7 +128,33 @@ class EventLogStore: created_at=as_naive_utc(_utc_now()), ) session.add(event) - await session.commit() + try: + await session.commit() + except sqlalchemy.exc.IntegrityError: + await session.rollback() + existing = ( + (await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id))) + .scalars() + .first() + ) + if existing is None or existing.workspace_id != workspace_id or existing.bot_id != bot_id: + raise + # Complete the ingress snapshot once a runner claims it. A + # second subscriber must not replace another run's identity. + for field, value in { + 'run_id': run_id, + 'runner_id': runner_id, + 'conversation_id': conversation_id, + 'thread_id': thread_id, + 'actor_id': actor_id, + 'actor_name': actor_name, + }.items(): + if getattr(existing, field) is None and value is not None: + setattr(existing, field, value) + if input_json and ('contents' in input_json or 'attachments' in input_json): + existing.input_json = json.dumps(input_json) + existing.input_summary = input_summary + await session.commit() return event_id diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index c7c9443eb..f650f686f 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -5,9 +5,11 @@ from __future__ import annotations import time import asyncio import contextlib +import copy import typing from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction @@ -18,6 +20,7 @@ from ...pipeline.pool import get_query_execution_context from .binding_resolver import AgentBindingResolver from .context_builder import RunnerContextBuilder, RunnerContextPayload from .descriptor import RunnerDescriptor +from .config_schema import extract_prompt_config from .execution_context import ( append_mcp_resource_context_to_event, build_mcp_resource_context_addition, @@ -37,15 +40,13 @@ from .run_journal import AgentRunJournal from .session_registry import AgentRunSessionRegistry, get_session_registry from .state_scope import build_state_context from ...provider.tools.loaders import skill as skill_loader +from ...telemetry import resources as telemetry_resources from ...telemetry import trace as trace_mod from ...telemetry.execution import bind_trace as bind_execution_trace from ...telemetry.execution import close_trace as close_execution_trace from ...telemetry.execution import record as record_execution -ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills' - - class AgentRunOrchestrator: """Coordinate one Runner execution. @@ -114,6 +115,12 @@ class AgentRunOrchestrator: if execution_query is None: execution_query = build_execution_query(event, []) + # Event runs bypass pipeline prompt preparation, just like debug runs. + prompt_config = extract_prompt_config(descriptor, binding.runner_config, []) + execution_query.prompt = provider_prompt.Prompt( + name='default', + messages=[provider_message.Message(**item) for item in copy.deepcopy(prompt_config)], + ) # Synthetic events must expose the same trusted scope as pipeline queries. for field_name in ('instance_uuid', 'workspace_uuid', 'placement_generation', 'query_uuid'): object.__setattr__(execution_query, field_name, getattr(execution_context, field_name)) @@ -142,6 +149,22 @@ class AgentRunOrchestrator: binding=binding, descriptor=descriptor, ) + # Telemetry: report what this run was granted (never what it used). The + # materialized resource list is authoritative for tools/skills/models; + # the Runner's own config form is authoritative for its selectors. + try: + from . import config_schema as runner_config_schema + + capabilities = getattr(descriptor, 'capabilities', None) + telemetry_resources.note_agent( + runner_id=descriptor.id, + resources=resources, + selectors=runner_config_schema.extract_selected_resources(descriptor, binding.runner_config), + capabilities=capabilities.model_dump() if hasattr(capabilities, 'model_dump') else None, + tools_all=getattr(binding.resource_policy, 'allow_all_tools', None), + ) + except Exception: + pass context = await self.context_builder.build_context_from_event( event=execution_event, @@ -150,24 +173,21 @@ class AgentRunOrchestrator: resources=resources, ) + authorized_skill_names = [ + str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name') + ] + # Every invocation owns a new run; conversation state is not a recovery snapshot. + prepare_execution_query(execution_query, event, authorized_skill_names) + execution_query.variables.pop(skill_loader.ACTIVATED_SKILLS_KEY, None) + session_query_id = None if adapter_context: - if execution_query is not None: - skill_loader.restore_activated_skills_from_state( - self.ap, - execution_query, - context.get('state', {}), - ) session_query_id = adapter_context.get('query_id') if execution_query is not None or session_query_id is not None: context['context']['available_apis']['prompt_get'] = True if 'params' in adapter_context: context['adapter']['extra']['params'] = adapter_context['params'] - authorized_skill_names = [ - str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name') - ] - prepare_execution_query(execution_query, event, authorized_skill_names) context['variables'] = { key: value for key, value in (execution_query.variables or {}).items() @@ -177,6 +197,21 @@ class AgentRunOrchestrator: state_context = build_state_context(event, binding, descriptor) run_id = context['run_id'] + + # Monitoring attributes every call to the processor that ran it and to the + # record that owns it; a Runner has to claim both itself, since no + # Pipeline did. Runs before the Runner is invoked and before the run + # context is handed to it, so its own variables stay private. + from ...pipeline import monitoring_helper + + await monitoring_helper.prepare_monitoring_identity( + self.ap, + execution_query, + processor_type=binding.processor_type, + processor_id=binding.processor_id, + runner_id=binding.runner_id, + execution_record_id=run_id, + ) context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any( tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform' for tool in resources.get('tools', []) @@ -232,6 +267,11 @@ class AgentRunOrchestrator: context=context, authorization=run_authorization, ) + monitoring_id = (execution_query.variables or {}).get('_monitoring_message_id') + if monitoring_id and monitoring_id != run_id: + await self.ap.monitoring_service.link_execution_message( + get_query_execution_context(execution_query), monitoring_id, run_id, event.event_id + ) await self._session_registry.register( run_id=run_id, runner_id=descriptor.id, @@ -314,16 +354,25 @@ class AgentRunOrchestrator: ): continue + journal_started = time.monotonic() await self.journal.append_run_result( result_dict=result_dict, run_id=run_id, sequence=sequence_int, ) + journal_ms = (time.monotonic() - journal_started) * 1000 # Trusted Host observers receive validated events before message-only normalization. result_observer = (adapter_context or {}).get('_result_observer') + observer_started = time.monotonic() if result_observer is not None: await result_observer(result_dict) + observer_ms = (time.monotonic() - observer_started) * 1000 + if journal_ms + observer_ms >= 100: + self.ap.logger.debug( + f'Slow runner event consumer run={run_id} type={result_type} ' + f'journal_ms={journal_ms:.1f} observer_ms={observer_ms:.1f}' + ) if result_type == 'state.updated': await self.journal.handle_state_updated_event( @@ -409,9 +458,15 @@ class AgentRunOrchestrator: terminal_status or '', 'unknown' ) execution_error = '' if execution_outcome == 'success' else (terminal_reason or execution_outcome) - except asyncio.CancelledError: + except (asyncio.CancelledError, GeneratorExit): execution_outcome = 'cancelled' execution_error = 'cancelled' + await self.journal.finalize_run( + run_id=run_id, + status='cancelled', + status_reason='consumer_cancelled', + usage=terminal_usage, + ) raise except Exception as exc: execution_outcome = 'timeout' if self._is_deadline_exhausted(context) else 'failed' diff --git a/src/langbot/pkg/agent/runner/persistent_state_store.py b/src/langbot/pkg/agent/runner/persistent_state_store.py index 7477838b8..4665f127a 100644 --- a/src/langbot/pkg/agent/runner/persistent_state_store.py +++ b/src/langbot/pkg/agent/runner/persistent_state_store.py @@ -191,7 +191,12 @@ class PersistentStateStore: # Seed external.conversation_id from event.conversation_id if not set if self._check_scope_enabled('conversation', binding) and event.conversation_id: if 'external.conversation_id' not in snapshot['conversation']: - snapshot['conversation']['external.conversation_id'] = event.conversation_id + from .context_reset import get_reset_generation + + generation = await get_reset_generation( + self._db_engine, event.workspace_id, event.bot_id, event.conversation_id + ) + snapshot['conversation']['external.conversation_id'] = generation or event.conversation_id return snapshot diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py index 70795f304..b744f865b 100644 --- a/src/langbot/pkg/agent/runner/platform_tools.py +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -60,7 +60,7 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = ( 'Call this tool to send a message; Agent output text is not sent automatically.', }, _object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']), - ('message.*', 'friend.*', 'group.*', 'feedback.*'), + ('message.*', 'friend.*', 'group.*', 'feedback.*', 'interaction.submitted'), 'reply_target', ), PlatformToolDefinition( @@ -465,7 +465,11 @@ def build_platform_tool_resources( reason = None if definition is None: reason = 'unknown_tool' - elif definition.api not in supported_apis: + elif definition.api not in supported_apis and not ( + definition.name == 'event_reply' + and {'reply_message', 'get_message'} <= supported_apis + and (event.delivery.reply_target or {}).get('message_id') + ): reason = 'adapter_api_unsupported' elif definition.scope == 'event' and not _event_matches(event.event_type, definition.event_patterns): reason = 'event_incompatible' @@ -727,9 +731,36 @@ async def execute_platform_tool( bot = await ap.platform_mgr.get_bot_by_uuid(execution_context, bot_id) if bot is None: raise ValueError(f'Bot {bot_id} is not running') - if definition.api not in set(bot.adapter.get_supported_apis() or []): + supported_apis = set(bot.adapter.get_supported_apis() or []) + reply_via_source = ( + definition.name == 'event_reply' + and 'send_message' not in supported_apis + and {'reply_message', 'get_message'} <= supported_apis + and (delivery.get('reply_target') or {}).get('message_id') + ) + if definition.api not in supported_apis and not reply_via_source: raise ValueError(f'Platform API {definition.api} is no longer supported by bot {bot_id}') api_func = getattr(bot.adapter, definition.api, None) + if reply_via_source: + + async def api_func(target_type, target_id, message): + source = await bot.adapter.get_message( + chat_type=target_type, + chat_id=target_id, + message_id=delivery['reply_target']['message_id'], + ) + return await bot.adapter.reply_message(source, message) + + # Some platforms require a one-time event credential instead of an + # ordinary conversation send. Only pass the Host-frozen event context. + event_reply_handler = getattr(bot.adapter, 'reply_to_event', None) + if definition.name == 'event_reply' and callable(event_reply_handler): + + async def api_func(target_type, target_id, message): + return await event_reply_handler( + context=context, target_type=target_type, target_id=target_id, message=message + ) + if not callable(api_func): raise ValueError(f'Platform API {definition.api} is declared but not implemented') if definition.api == 'send_message': @@ -743,10 +774,45 @@ async def execute_platform_tool( from ...telemetry.platform import processing_mode token = processing_mode.set(authorization.get('processor_type', 'none')) + delivery_error = None try: return await api_func(**normalized) + except Exception as exc: + delivery_error = str(exc) + raise finally: processing_mode.reset(token) + if definition.api == 'send_message' and getattr(ap, 'monitoring_service', None) is not None: + try: + import json + + chain = normalized.get('message') + content = chain.model_dump(mode='json') if hasattr(chain, 'model_dump') else str(chain or '') + await ap.monitoring_service.record_message( + execution_context, + bot_id=bot_id, + bot_name=str(getattr(getattr(bot, 'bot_entity', None), 'name', bot_id)), + pipeline_id=str(authorization.get('processor_id') or ''), + pipeline_name=str(authorization.get('processor_id') or authorization.get('runner_id') or ''), + message_content=content + if isinstance(content, (list, dict)) + else json.dumps(content, ensure_ascii=False), + session_id=str(authorization.get('conversation_id') or ''), + role='assistant', + status='error' if delivery_error else 'success', + level='error' if delivery_error else 'info', + run_id=execution_id, + parent_message_id=execution_id, + variables=json.dumps( + { + 'delivery_error': delivery_error, + 'target_type': normalized.get('target_type'), + 'target_id': normalized.get('target_id'), + } + ), + ) + except Exception as exc: + ap.logger.warning(f'Failed to record platform delivery: {exc}') finally: reset_execution_id(execution_token) diff --git a/src/langbot/pkg/agent/runner/query_bridge.py b/src/langbot/pkg/agent/runner/query_bridge.py index 9932bf785..84923ce17 100644 --- a/src/langbot/pkg/agent/runner/query_bridge.py +++ b/src/langbot/pkg/agent/runner/query_bridge.py @@ -36,7 +36,9 @@ class QueryRunBridge: """Build an event-first run plan from a Pipeline Query.""" runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) if not runner_id: - raise RunnerNotFoundError('no runner configured') + raise RunnerNotFoundError( + 'no runner configured; legacy (pre-4.11) pipelines must be migrated with the pipeline migration assistant' + ) event = QueryEntryAdapter.query_to_event(query) agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index 7999f9efd..a3ecbfe16 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -335,6 +335,8 @@ class QueryEntryAdapter: # Handle launcher_id launcher_id = getattr(query, 'launcher_id', None) + if launcher_id is not None: + launcher_id = str(launcher_id) # Build session_id from launcher info if available session_id = None diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 76d5dd1c4..4e113f8f0 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -106,6 +106,8 @@ class AgentResourceBuilder: descriptor, runner_config, ) + if getattr(self.ap, 'skill_mgr', None) is not None: + await self.ap.skill_mgr.reload_skills(execution_context) skills = self._build_skills_from_binding( execution_context, resource_policy, diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py index dd2bfbf27..c2ea15df8 100644 --- a/src/langbot/pkg/agent/runner/run_journal.py +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -5,6 +5,7 @@ from __future__ import annotations import typing from ...core import app +from ...storage.media import MediaCache from .descriptor import RunnerDescriptor from .errors import RunnerProtocolError from .host_models import AgentBinding, AgentEventEnvelope @@ -44,10 +45,10 @@ class AgentRunJournal: if not item: return value item_type = item.get('type') - if item_type == 'image_base64' and item.get('image_base64'): - item['image_base64'] = None + if MediaCache.has_inline_images(item): + item = MediaCache.redact_inline_images(item) item['content_redacted'] = True - elif item_type == 'file_base64' and item.get('file_base64'): + if item_type == 'file_base64' and item.get('file_base64'): item['file_base64'] = None item['content_redacted'] = True return item @@ -68,6 +69,28 @@ class AgentRunJournal: def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]: return [cls._sanitize_attachment_ref(attachment) for attachment in attachments] + @classmethod + def _sanitize_event_data(cls, value: typing.Any) -> typing.Any: + """Retain arbitrary event fields without copying inline binary payloads.""" + if hasattr(value, 'model_dump'): + value = value.model_dump(mode='json') + if isinstance(value, list): + return [cls._sanitize_event_data(item) for item in value] + if not isinstance(value, dict): + return value + if value.get('type') in ('Image', 'image', 'image_url', 'image_base64'): + value = cls._sanitize_content_item(value) + result = {} + for key, item in value.items(): + if key in ('image_base64', 'file_base64', 'base64') and item: + result[key] = None + result['content_redacted'] = True + elif key == 'attachments' and isinstance(item, list): + result[key] = [cls._sanitize_event_data(cls._sanitize_attachment_ref(ref)) for ref in item] + else: + result[key] = cls._sanitize_event_data(item) + return result + async def create_run( self, *, @@ -78,6 +101,9 @@ class AgentRunJournal: authorization: dict[str, typing.Any], ) -> dict[str, typing.Any]: """Create the Host-owned run ledger record.""" + from ...telemetry import trace + + trace_state = trace.current() runtime = context.get('runtime') if isinstance(context, dict) else {} return await self._get_run_ledger_store().create_run( run_id=context['run_id'], @@ -93,17 +119,21 @@ class AgentRunJournal: authorization=authorization, metadata={ 'event_type': event.event_type, + 'ingress_event_id': trace_state.event_id if trace_state else event.event_id, 'source': event.source, 'processor_id': binding.processor_id, 'processor_type': binding.processor_type, **( - {'input_event': event.data, 'delivery': event.delivery.model_dump(mode='json')} + { + 'input_event': self._sanitize_event_data(event.data), + 'delivery': event.delivery.model_dump(mode='json'), + } if binding.processor_type == 'event_processor' else {} ), **( { - **({'input_event': event.data} if event.event_type != 'message.received' else {}), + 'input_event': self._sanitize_event_data(event.data), 'input': { 'text': context.get('input', {}).get('text', ''), 'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])), @@ -130,11 +160,16 @@ class AgentRunJournal: usage = result_dict.get('usage') if hasattr(usage, 'model_dump'): usage = usage.model_dump(mode='json') + data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {} + if MediaCache.has_inline_images(data): + data = await self.ap.storage_mgr.media_cache.externalize_chain_dump(data) + if MediaCache.has_inline_images(metadata): + metadata = MediaCache.redact_inline_images(metadata) return await self._get_run_ledger_store().append_event( run_id=run_id, sequence=sequence, event_type=str(result_dict.get('type') or 'unknown'), - data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}, + data=data, usage=usage if isinstance(usage, dict) else None, source=source, metadata=metadata, @@ -260,7 +295,7 @@ class AgentRunJournal: event_time=( datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None ), - metadata=metadata, + metadata={**(metadata or {}), 'input_event': self._sanitize_event_data(event.data)}, ) async def write_user_transcript( diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py index 1cb3d476d..06ec1d969 100644 --- a/src/langbot/pkg/agent/runner/run_ledger_store.py +++ b/src/langbot/pkg/agent/runner/run_ledger_store.py @@ -12,6 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import sessionmaker from ...persistence.datetime_utils import as_naive_utc +from ...storage.media import MediaCache +from ...utils.inflight import inflight_hub +from ...utils.execution_lease import host_execution_lease from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime @@ -136,6 +139,7 @@ class RunLedgerStore: thread_id=thread_id, workspace_id=workspace_id, bot_id=bot_id, + **(host_execution_lease() if status == 'running' and queue_name is None else {}), status=status, queue_name=queue_name, priority=priority, @@ -149,6 +153,7 @@ class RunLedgerStore: ) session.add(run) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def claim_next_run( @@ -206,6 +211,7 @@ class RunLedgerStore: run.last_claimed_at = as_naive_utc(now) run.updated_at = as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run, include_claim_token=True) async def renew_claim( @@ -226,6 +232,7 @@ class RunLedgerStore: run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1))) run.updated_at = as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def release_claim( @@ -254,6 +261,7 @@ class RunLedgerStore: if status in TERMINAL_STATUSES: run.finished_at = run.finished_at or as_naive_utc(now) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def release_expired_claims( @@ -332,6 +340,7 @@ class RunLedgerStore: ) session.add(row) await session.commit() + inflight_hub.notify_run(run_id, event_type) return self._event_to_dict(row) async def append_audit_event( @@ -366,8 +375,36 @@ class RunLedgerStore: ) session.add(row) await session.commit() + inflight_hub.notify_run(run_id, event_type) return self._event_to_dict(row) + async def pin_skill_revision(self, run_id: str, workspace_id: str, name: str, revision: str) -> str: + """Pin a Host-owned revision without overwriting concurrent run metadata.""" + async with self._session_factory() as session: + run = await self._get_run_row(session, run_id) + if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES: + raise ValueError('Skill activation requires an active run in the current Workspace') + original = run.metadata_json + metadata = MediaCache.redact_inline_images(_json_loads(original, {})) + bindings = metadata.setdefault('activated_skills', {}) + if name in bindings: + return str(bindings[name]) + bindings[name] = revision + result = await session.execute( + sqlalchemy.update(AgentRun) + .where( + AgentRun.run_id == run_id, + AgentRun.metadata_json == original, + AgentRun.status.notin_(TERMINAL_STATUSES), + ) + .values(metadata_json=_json_dumps(metadata)) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise ValueError('Concurrent run update; retry Skill activation') + await session.commit() + return revision + async def finalize_run( self, *, @@ -403,13 +440,15 @@ class RunLedgerStore: if cost is not None: run.cost_json = _json_dumps(cost) if metadata is not None: - existing_metadata = _json_loads(run.metadata_json, {}) + # Discard legacy image bytes before merging; new metadata still uses the write contract. + existing_metadata = MediaCache.redact_inline_images(_json_loads(run.metadata_json, {})) if isinstance(existing_metadata, dict): existing_metadata.update(metadata) run.metadata_json = _json_dumps(existing_metadata) else: run.metadata_json = _json_dumps(metadata) await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def validate_active_claim( @@ -443,6 +482,7 @@ class RunLedgerStore: run.updated_at = as_naive_utc(now) run.status_reason = status_reason or run.status_reason await session.commit() + inflight_hub.notify(run.workspace_id) return self._run_to_dict(run) async def get_run(self, run_id: str) -> dict[str, typing.Any] | None: diff --git a/src/langbot/pkg/agent/runner/transcript_store.py b/src/langbot/pkg/agent/runner/transcript_store.py index aec02502b..426740746 100644 --- a/src/langbot/pkg/agent/runner/transcript_store.py +++ b/src/langbot/pkg/agent/runner/transcript_store.py @@ -335,6 +335,9 @@ class TranscriptStore: thread_id: str | None, strict_thread: bool, ) -> typing.Any: + from .context_reset import active_history_condition + + query = query.where(active_history_condition(Transcript)) if bot_id is not None: query = query.where(Transcript.bot_id == bot_id) if workspace_id is not None: diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index bb9fcc048..6cafbdc8c 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -17,6 +17,8 @@ from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ....core.errors import TaskCapacityError from ....provider.modelmgr.codex_errors import CodexProviderError +from ....i18n import DEFAULT_LANGUAGE, language, language_scope, t, error_text +from ....i18n.workspace import get_workspace_language from ..authz import ( AuthenticationDeniedError, AuthorizationError, @@ -137,7 +139,7 @@ class RouterGroup(abc.ABC): # receive RequestContext or enforce Workspace permissions. self._inject_handler_context(f, kwargs, user_email, None, account) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.USER_TOKEN: # get token from Authorization header @@ -160,7 +162,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, user_email, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.API_KEY: # get API key from Authorization header or X-API-Key header @@ -179,7 +181,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) elif auth_type == AuthType.USER_TOKEN_OR_API_KEY: token = quart.request.headers.get('Authorization', '').replace('Bearer ', '') @@ -193,7 +195,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) # Try API key first (check X-API-Key header) elif api_key := quart.request.headers.get('X-API-Key', ''): # API key authentication @@ -203,7 +205,7 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) else: # Try user token authentication (Authorization header) if not token: @@ -223,7 +225,7 @@ class RouterGroup(abc.ABC): # Authentication succeeded and authorization was # evaluated. Do not reinterpret a denied user token # as an API key, which would mask the stable 403/404. - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) except Exception: # If user token fails, maybe it's an API key in Authorization header try: @@ -232,10 +234,12 @@ class RouterGroup(abc.ABC): require_permission(request_context, permission) self._inject_handler_context(f, kwargs, None, request_context) except Exception as e: - return self._auth_error_response(e) + return await self._localized_auth_error_response(e) observed_at = time.monotonic() try: + if request_context is not None: + language.set(await get_workspace_language(self.ap, request_context.workspace_uuid)) if request_context is not None: with bounded_executor.blocking_work_scope(request_context.workspace_uuid): persistence_mgr = getattr( @@ -292,6 +296,8 @@ class RouterGroup(abc.ABC): ) if isinstance(e, CodexProviderError): return self.http_status(e.status_code, e.error_code, str(e)) + if isinstance(e, EntitlementUnavailableError): + return self.http_status(403, 'entitlement_unavailable', str(e)) if isinstance(e, AuthorizationError): return self.http_status(e.status_code, e.error_code, str(e)) if isinstance(e, WorkspaceNotFoundError): @@ -319,7 +325,11 @@ class RouterGroup(abc.ABC): ) return self.internal_error_response(request_id) - new_f = handler_error + async def new_f(*args, **kwargs): + # Reset even on errors; child tasks inherit this request's locale. + with language_scope(quart.request.headers.get('X-LangBot-Language', DEFAULT_LANGUAGE)): + return await handler_error(*args, **kwargs) + # Quart/Flask requires a unique endpoint name even when the same URL # intentionally has separate handlers for different HTTP methods. # Include the method set so CRUD routes can declare distinct @@ -687,6 +697,14 @@ class RouterGroup(abc.ABC): 'Invalid authentication credentials', ) + async def _localized_auth_error_response(self, error: Exception): + # A resolved context is trusted even when its permissions deny this + # operation. Never look up a language using the unverified selector. + context = getattr(quart.g, 'request_context', None) + if context is not None: + language.set(await get_workspace_language(self.ap, context.workspace_uuid)) + return self._auth_error_response(error) + def request_id(self) -> str: """Return one stable request ID for authentication, logs, and errors.""" @@ -707,7 +725,7 @@ class RouterGroup(abc.ABC): response = quart.jsonify( { 'code': 'internal_error', - 'msg': 'Internal server error', + 'msg': t('Internal server error'), 'request_id': resolved_request_id, } ) @@ -729,7 +747,7 @@ class RouterGroup(abc.ABC): return quart.jsonify( { 'code': code, - 'msg': msg, + 'msg': error_text(code, msg), } ) diff --git a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py index bf3fc03bd..700eab509 100644 --- a/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py +++ b/src/langbot/pkg/api/http/controller/groups/agent_debug_stream.py @@ -2,6 +2,8 @@ from __future__ import annotations +from .....i18n import t + import asyncio import contextlib import json @@ -45,20 +47,20 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q if isinstance(exc, RunnerExecutionError): code, message = exc.error_code or 'runner_execution_failed', exc.message elif isinstance(exc, RunnerNotFoundError): - code, message = 'runner_not_found', 'The configured Agent runner is unavailable' + code, message = 'runner_not_found', t('Agent runner temporarily unavailable. Please try again.') elif isinstance(exc, RunnerNotAuthorizedError): - code, message = 'runner_not_authorized', 'The configured Agent runner is not authorized' + code, message = 'runner_not_authorized', t('Agent runner not authorized for this pipeline.') elif isinstance(exc, RunnerProtocolError): - code, message = 'runner_protocol_error', 'The Agent runner returned an invalid response' + code, message = 'runner_protocol_error', t('The Agent runner returned an invalid response.') elif isinstance(exc, ValueError): code, message = 'invalid_request', str(exc) elif isinstance(exc, RunnerError): - code, message = 'runner_error', 'The Agent runner could not complete this test' + code, message = 'runner_error', t('Agent runner execution failed.') else: logger = getattr(application, 'logger', None) if logger is not None: logger.exception('Agent debug stream execution failed') - code, message = 'runner_error', 'The Agent debug execution failed' + code, message = 'runner_error', t('Agent runner execution failed.') await queue.put({'kind': 'error', 'code': code, 'msg': message}) task = asyncio.create_task(execute()) diff --git a/src/langbot/pkg/api/http/controller/groups/assistant.py b/src/langbot/pkg/api/http/controller/groups/assistant.py index f902c6f44..7684db2e1 100644 --- a/src/langbot/pkg/api/http/controller/groups/assistant.py +++ b/src/langbot/pkg/api/http/controller/groups/assistant.py @@ -1,5 +1,8 @@ """Web-session-only assistant endpoints; resource tools use the existing service layer.""" +import asyncio +import json + import quart from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -10,6 +13,11 @@ from ...context import RequestContext from ...service.assistant import AssistantError, AssistantService +class StopInput(BaseModel): + model_config = ConfigDict(extra='forbid') + revision: int = Field(ge=0, strict=True) + + class TurnInput(BaseModel): model_config = ConfigDict(extra='forbid') revision: int = Field(ge=0, strict=True) @@ -23,6 +31,27 @@ class AssistantRouterGroup(group.RouterGroup): async def initialize(self): service = AssistantService(self.ap) + @self.route('/conversations', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def list_conversations(request_context: RequestContext): + return self.success(data={'conversations': await service.list_conversations(request_context)}) + + @self.route('/conversations//stop', methods=['POST'], permission=Permission.RUNTIME_OPERATE) + async def stop(conversation_id: str, request_context: RequestContext): + try: + body = StopInput.model_validate(await quart.request.get_json()) + return self.success(data=await service.stop(request_context, conversation_id, body.revision)) + except ValidationError: + return self.http_status(400, 'invalid_input', 'Invalid stop request') + except AssistantError as exc: + return self.http_status(exc.status, exc.code, exc.code) + + @self.route('/recommended-model', methods=['GET'], permission=Permission.RUNTIME_OPERATE) + async def recommended_model(request_context: RequestContext): + try: + return self.success(data=await service.recommended_model(request_context)) + except AssistantError as exc: + return self.http_status(exc.status, exc.code, exc.code) + @self.route('/conversations', methods=['POST'], permission=Permission.RUNTIME_OPERATE) async def create(request_context: RequestContext): try: @@ -33,7 +62,7 @@ class AssistantRouterGroup(group.RouterGroup): @self.route('/conversations/', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get(conversation_id: str, request_context: RequestContext): try: - return self.success(data=service.public_view(await service.get(request_context, conversation_id))) + return self.success(data=await service.progress(request_context, conversation_id)) except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) @@ -56,3 +85,43 @@ class AssistantRouterGroup(group.RouterGroup): return self.http_status(400, 'invalid_input', 'Invalid assistant request') except AssistantError as exc: return self.http_status(exc.status, exc.code, exc.code) + + @self.route( + '/conversations//turn/stream', methods=['POST'], permission=Permission.RUNTIME_OPERATE + ) + async def stream_turn(conversation_id: str, request_context: RequestContext): + try: + body = TurnInput.model_validate(await quart.request.get_json()) + if (body.text is None) == (body.approved is None) or (body.text is not None and not body.text.strip()): + return self.http_status(400, 'invalid_input', 'Provide text or an approval decision') + await service.get(request_context, conversation_id) + except ValidationError: + return self.http_status(400, 'invalid_input', 'Invalid assistant request') + except AssistantError as exc: + return self.http_status(exc.status, exc.code, exc.code) + try: + return assistant_stream_response(service, request_context, conversation_id, body) + except AssistantError as exc: + return self.http_status(exc.status, exc.code, exc.code) + + +def assistant_stream_response(service, context, conversation_id, body): + """Detach execution from the subscriber; only an explicit stop cancels a run.""" + queue = service.start_run(context, conversation_id, body) + + async def stream(): + while True: + try: + frame = await asyncio.wait_for(queue.get(), timeout=15) + except TimeoutError: + yield '\n' + continue + yield json.dumps(frame, ensure_ascii=False) + '\n' + if frame['kind'] in {'completed', 'error'}: + break + + response = quart.Response(stream(), content_type='application/x-ndjson; charset=utf-8') + response.timeout = None + response.headers['Cache-Control'] = 'no-store' + response.headers['X-Accel-Buffering'] = 'no' + return response diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 01a901743..d6feeb2bc 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -24,6 +24,24 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None: return dt +def validate_monitoring_window( + start_time: datetime.datetime | None, + end_time: datetime.datetime | None, + *, + allow_point_window: bool = False, +) -> None: + """Bound explicit dashboard windows to one year without silently truncating them.""" + if start_time is None: + return + end = end_time or datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + if ( + start_time > end + or (start_time == end and not allow_point_window) + or end - start_time > datetime.timedelta(days=365) + ): + quart.abort(400, description='Monitoring time range must be positive and no longer than 365 days') + + @group.group_class('monitoring', '/api/v1/monitoring') class MonitoringRouterGroup(group.RouterGroup): async def initialize(self) -> None: @@ -39,6 +57,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) metrics = await self.ap.monitoring_service.get_overview_metrics( request_context, @@ -50,6 +69,80 @@ class MonitoringRouterGroup(group.RouterGroup): return self.success(data=metrics) + @self.route('/in-flight/stream', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def stream_inflight(request_context: RequestContext): + from .....utils.inflight import inflight_hub + from ...service.tenant import require_workspace_uuid + import json + + workspace = require_workspace_uuid(request_context) + previous = [] + + async def snapshot(): + nonlocal previous + result = await self.ap.monitoring_service.get_inflight_snapshot(request_context, previous) + previous = [row for row in result['items'] if row['status_group'] in {'running', 'queued'}] + return result + + async def frames(): + async for frame in inflight_hub.watch(workspace, snapshot): + yield 'data: ' + json.dumps(frame, ensure_ascii=False) + '\n\n' + + response = quart.Response(frames(), content_type='text/event-stream') + response.headers['Cache-Control'] = 'no-cache, no-store' + response.headers['X-Accel-Buffering'] = 'no' + response.timeout = None + return response + + @self.route('/executions', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def get_executions(request_context: RequestContext) -> str: + """Unified execution list across agent runs and pipeline queries.""" + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + agent_ids = quart.request.args.getlist('agentId') + statuses = quart.request.args.getlist('status') + source = quart.request.args.get('source', 'all') + mode = quart.request.args.get('mode', 'all') + start_time = parse_iso_datetime(quart.request.args.get('startTime')) + end_time = parse_iso_datetime(quart.request.args.get('endTime')) + validate_monitoring_window(start_time, end_time) + + result = await self.ap.monitoring_service.get_executions( + request_context, + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + agent_ids=agent_ids if agent_ids else None, + statuses=statuses if statuses else None, + source=source, + mode=mode, + start_time=start_time, + end_time=end_time, + limit=quart.request.args.get('limit', 50), + offset=quart.request.args.get('offset', 0), + ) + + return self.success(data=result) + + @self.route( + '/executions//', + methods=['GET'], + permission=Permission.RESOURCE_VIEW, + ) + async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str: + """Read a full trace, its initial overview, or one paginated section.""" + try: + detail = await self.ap.monitoring_service.get_execution_detail( + request_context, + source, + execution_id, + section=quart.request.args.get('section'), + offset=quart.request.args.get('offset', 0), + limit=quart.request.args.get('limit', 100), + ) + except ValueError as exc: + return self.fail(404, str(exc)) + return self.success(data=detail) + @self.route('/token-statistics', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_token_statistics(request_context: RequestContext) -> str: """Get detailed token usage statistics (summary, per-model, timeseries).""" @@ -63,9 +156,12 @@ class MonitoringRouterGroup(group.RouterGroup): start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) stats = await self.ap.monitoring_service.get_token_statistics( request_context, + execution_statuses=quart.request.args.getlist('status') or None, + mode=quart.request.args.get('mode', 'all'), bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -96,6 +192,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) messages, total = await self.ap.monitoring_service.get_messages( request_context, @@ -137,6 +234,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) llm_calls, total = await self.ap.monitoring_service.get_llm_calls( request_context, @@ -176,6 +274,7 @@ class MonitoringRouterGroup(group.RouterGroup): start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) tool_calls, total = await self.ap.monitoring_service.get_tool_calls( request_context, @@ -216,6 +315,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls( request_context, @@ -257,6 +357,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) # Parse is_active is_active = None @@ -304,6 +405,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) errors, total = await self.ap.monitoring_service.get_errors( request_context, @@ -327,6 +429,10 @@ class MonitoringRouterGroup(group.RouterGroup): @self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_all_data(request_context: RequestContext) -> str: """Get all monitoring data in a single request""" + execution_filters = { + 'execution_statuses': quart.request.args.getlist('status') or None, + 'mode': quart.request.args.get('mode', 'all'), + } # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') @@ -337,10 +443,12 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) # Get overview metrics overview = await self.ap.monitoring_service.get_overview_metrics( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -350,6 +458,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get messages messages, messages_total = await self.ap.monitoring_service.get_messages( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -361,6 +470,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get LLM calls llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -372,6 +482,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get tool calls tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -383,6 +494,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get sessions sessions, sessions_total = await self.ap.monitoring_service.get_sessions( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -395,6 +507,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get errors errors, errors_total = await self.ap.monitoring_service.get_errors( request_context, + **execution_filters, bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -406,6 +519,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Get embedding calls embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls( request_context, + **execution_filters, start_time=start_time, end_time=end_time, limit=limit, @@ -417,6 +531,7 @@ class MonitoringRouterGroup(group.RouterGroup): 'traffic': await get_traffic_series( self.ap, request_context, + **execution_filters, bot_ids=bot_ids or None, pipeline_ids=pipeline_ids or None, start_time=start_time, @@ -440,6 +555,30 @@ class MonitoringRouterGroup(group.RouterGroup): } ) + @self.route( + '/sessions/reset-context', + methods=['POST'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.RESOURCE_MANAGE, + ) + async def reset_session_context(request_context: RequestContext): + body = await quart.request.get_json() + if not isinstance(body, dict): + return self.http_status(400, 'invalid_request', 'Expected a JSON object') + try: + result = await self.ap.monitoring_service.reset_session_context( + request_context, + body.get('bot_id'), + body.get('session_id'), + ) + except ValueError as exc: + return self.http_status(400, 'invalid_request', str(exc)) + except LookupError as exc: + return self.http_status(404, 'resource_not_found', str(exc)) + except RuntimeError as exc: + return self.http_status(409, 'session_busy', str(exc)) + return self.success(data=result) + @self.route( '/sessions//analysis', methods=['GET'], @@ -450,6 +589,8 @@ class MonitoringRouterGroup(group.RouterGroup): """Get detailed analysis for a specific session""" start_time = parse_iso_datetime(quart.request.args.get('startTime')) end_time = parse_iso_datetime(quart.request.args.get('endTime')) + # Message pages can contain one timestamp; tool-call bounds are inclusive. + validate_monitoring_window(start_time, end_time, allow_point_window=True) analysis = await self.ap.monitoring_service.get_session_analysis( request_context, session_id, @@ -491,6 +632,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) # Get data based on export type if export_type == 'messages': @@ -639,7 +781,7 @@ class MonitoringRouterGroup(group.RouterGroup): 'platform', ] else: - return self.error(message=f'Invalid export type: {export_type}', code=400) + return self.fail(400, f'Invalid export type: {export_type}') # Generate CSV content with UTF-8 BOM for Excel compatibility import io @@ -681,9 +823,12 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) stats = await self.ap.monitoring_service.get_feedback_stats( request_context, + execution_statuses=quart.request.args.getlist('status') or None, + mode=quart.request.args.get('mode', 'all'), bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, @@ -707,12 +852,15 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse datetime start_time = parse_iso_datetime(start_time_str) end_time = parse_iso_datetime(end_time_str) + validate_monitoring_window(start_time, end_time) # Parse feedback type feedback_type = int(feedback_type_str) if feedback_type_str else None feedback_list, total = await self.ap.monitoring_service.get_feedback_list( request_context, + execution_statuses=quart.request.args.getlist('status') or None, + mode=quart.request.args.get('mode', 'all'), bot_ids=bot_ids if bot_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None, feedback_type=feedback_type, diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py index 88deea8e6..9f32a63d3 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py @@ -21,6 +21,8 @@ import httpx import quart from ... import group +from ......i18n import language, t +from ......i18n.workspace import get_workspace_language from ......utils import httpclient, paths from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks @@ -73,6 +75,7 @@ class EmbedRouterGroup(group.RouterGroup): bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid) pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid: + language.set(await get_workspace_language(self.ap, bot.execution_context.workspace_uuid)) return bot, pipeline_uuid return None, None @@ -284,7 +287,7 @@ class EmbedRouterGroup(group.RouterGroup): return self.http_status(404, -1, 'WebSocket adapter not found') websocket_adapter.reset_session(pipeline_uuid, session_type, session_id) - return self.success(data={'message': 'Session reset successfully'}) + return self.success(data={'message': t('Session reset successfully')}) except Exception as e: logger.error(f'Failed to reset embed session: {e}', exc_info=True) @@ -338,7 +341,9 @@ class EmbedRouterGroup(group.RouterGroup): runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid) if runtime_bot is None: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'})) + await quart.websocket.send( + json.dumps({'type': 'error', 'message': t('Bot not found or not available')}) + ) return session_type = quart.websocket.args.get('session_type', 'person') @@ -357,13 +362,15 @@ class EmbedRouterGroup(group.RouterGroup): await self._authenticate_websocket(runtime_bot) await self._assert_execution_active(runtime_bot) except Exception: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')})) return try: proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context) websocket_adapter = proxy_bot.adapter if not websocket_adapter: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) + await quart.websocket.send( + json.dumps({'type': 'error', 'message': t('WebSocket adapter not found')}) + ) return connection = await ws_connection_manager.add_connection( @@ -428,7 +435,7 @@ class EmbedRouterGroup(group.RouterGroup): except Exception as e: logger.error(f'Embed WebSocket connection error: {e}', exc_info=True) try: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')})) except Exception: pass @@ -451,7 +458,7 @@ class EmbedRouterGroup(group.RouterGroup): try: current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid) except Exception: - await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'}) + await connection.send_queue.put({'type': 'error', 'message': t('Bot is unavailable')}) break await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot) elif message_type == 'disconnect': @@ -460,7 +467,7 @@ class EmbedRouterGroup(group.RouterGroup): pass except json.JSONDecodeError: - await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')}) except Exception as e: logger.error(f'Embed receive error: {e}', exc_info=True) 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 16406de90..649293e10 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 @@ -2,6 +2,9 @@ from __future__ import annotations +from ......i18n import language, t +from ......i18n.workspace import get_workspace_language + import asyncio import datetime import json @@ -217,8 +220,9 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: request_context, token = await self._authenticate_websocket() except Exception: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')})) return + language.set(await get_workspace_language(self.ap, request_context.workspace_uuid)) session_type = quart.websocket.args.get('session_type', 'person') if session_type not in ['person', 'group']: await quart.websocket.send( @@ -229,7 +233,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid) if websocket_adapter is None: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Pipeline not found')})) return connection = await ws_connection_manager.add_connection( @@ -294,7 +298,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): except Exception: logger.error('Dashboard WebSocket connection error', exc_info=True) try: - await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'})) + await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')})) except Exception: pass @@ -419,7 +423,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): try: request_context = await self._revalidate_websocket_authorization(request_context, token) except Exception: - await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'}) + await connection.send_queue.put({'type': 'error', 'message': t('Unauthorized')}) break await websocket_adapter.handle_websocket_message(connection, data) elif message_type == 'disconnect': @@ -427,7 +431,7 @@ class WebSocketChatRouterGroup(group.RouterGroup): else: logger.warning(f'Unknown WebSocket message type: {message_type}') except json.JSONDecodeError: - await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'}) + await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')}) except Exception: logger.error('Dashboard WebSocket receive error', exc_info=True) diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 75a046e8c..d740221f1 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -1,3 +1,5 @@ +from langbot.pkg.i18n import t, DEFAULT_LANGUAGE +from langbot.pkg.i18n.workspace import get_workspace_language import asyncio import dataclasses import mimetypes @@ -6,6 +8,7 @@ import quart from langbot.pkg.api.http.authz import Permission from langbot.pkg.api.http.context import RequestContext +from langbot.pkg.cloud.plan_policy import workspace_entitlement, platform_feature_names, require_workspace_platform from langbot.pkg.core.errors import TaskCapacityError from langbot.pkg.utils import httpclient, importutil @@ -158,12 +161,98 @@ def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str: @group.group_class('adapters', '/api/v1/platform/adapters') class AdaptersRouterGroup(group.RouterGroup): async def initialize(self) -> None: + from ....service.slack_setup import get_slack_setup, SlackSetupError + + slack_setup = get_slack_setup(self.ap) + + @self.route( + '/slack/setup', + methods=['POST'], + permission=Permission.RESOURCE_MANAGE, + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def slack_start(request_context: RequestContext): + data = await quart.request.get_json() + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid_input') + try: + result = await slack_setup.start( + request_context, + **{ + key: data[key] + for key in ( + 'bot_uuid', + 'access_token', + 'refresh_token', + 'webhook_url', + 'name', + 'socket_mode', + 'redirect_url', + ) + if key in data + }, + ) + return self.success(data=result) + except (SlackSetupError, TypeError) as exc: + return self.http_status(400, -1, str(exc) if isinstance(exc, SlackSetupError) else 'invalid_input') + + @self.route( + '/slack/setup/', + methods=['GET', 'DELETE'], + permission=Permission.RESOURCE_MANAGE, + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + ) + async def slack_status(session_id: str, request_context: RequestContext): + try: + slack_setup.owned(request_context, session_id) + if quart.request.method == 'DELETE': + slack_setup.discard(session_id) + return self.success(data={}) + response = await quart.make_response(self.success(data=slack_setup.status(request_context, session_id))) + response.headers['Cache-Control'] = 'no-store' + return response + except SlackSetupError: + return self.http_status(404, -1, 'session_not_found') + + @self.route('/slack/setup/callback', methods=['GET'], auth_type=group.AuthType.NONE) + async def slack_callback(): + session = slack_setup.sessions.get(quart.request.args.get('state', '')) + locale = DEFAULT_LANGUAGE + if session is not None: + locale = await get_workspace_language(self.ap, session['context'].workspace_uuid) + try: + await slack_setup.finish( + quart.request.args.get('state', ''), + quart.request.args.get('code', ''), + quart.request.args.get('error'), + ) + message = t('Slack authorization completed. Return to LangBot to finish setup.', locale=locale) + status = 200 + except SlackSetupError: + message = t('Slack authorization failed or expired. Return to LangBot for details.', locale=locale) + status = 400 + return quart.Response( + message, + status=status, + content_type='text/plain; charset=utf-8', + headers={'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer'}, + ) + @self.route('', methods=['GET']) - async def _() -> str: - return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()}) + async def _(request_context: RequestContext) -> str: + snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid) + adapters = self.ap.platform_mgr.get_available_adapters_info() + if snapshot is not None: + adapters = [ + a + for a in adapters + if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name'])) + ] + return self.success(data={'adapters': adapters}) @self.route('/', methods=['GET']) - async def _(adapter_name: str) -> str: + async def _(adapter_name: str, request_context: RequestContext) -> str: + await require_workspace_platform(self.ap, request_context.workspace_uuid, adapter_name) adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name) if adapter_info is None: @@ -187,6 +276,15 @@ class AdaptersRouterGroup(group.RouterGroup): importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] ) + @self.route('/dingtalk/stream-card-template', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> quart.Response: + filename = 'dingtalk_stream_card.json' + response = quart.Response( + importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json' + ) + response.headers['Content-Disposition'] = f'attachment; filename={filename}' + return response + @self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE) async def _() -> quart.Response: filename = 'dingtalk_human_input_card.json' diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 950a8954d..28024ca5d 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -1,5 +1,7 @@ from __future__ import annotations +from .....i18n import t + import asyncio import base64 import collections.abc @@ -296,14 +298,20 @@ class PluginsRouterGroup(group.RouterGroup): async def _check_extensions_limit(self, request_context: RequestContext) -> str | None: """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" await self.ap.plugin_connector.require_workspace_context(request_context) - limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + from ...service.workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, request_context.workspace_uuid) max_extensions = limitation.get('max_extensions', -1) if max_extensions >= 0: plugins = await self.ap.plugin_connector.list_plugins() mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context) total_extensions = len(plugins) + len(mcp_servers) if total_extensions >= max_extensions: - return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached') + return self.http_status( + 400, + -1, + t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions), + ) return None @staticmethod diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py index 32a15e947..678550b69 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/models.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py @@ -14,6 +14,26 @@ MODEL_TEST_ERROR_CODE = 'model_test_failed' @group.group_class('models/llm', '/api/v1/provider/models/llm') class LLMModelsRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.route('/_/starred', methods=['GET'], permission=Permission.RESOURCE_VIEW) + async def get_starred(request_context: RequestContext) -> str: + return self.success(data={'uuid': await self.ap.llm_model_service.get_starred_model(request_context)}) + + @self.route('/_/starred', methods=['PUT'], permission=Permission.PROVIDER_SECRET_MANAGE) + async def set_starred(request_context: RequestContext) -> str: + body = await quart.request.json + model_uuid = body.get('uuid') + if model_uuid is not None and not isinstance(model_uuid, str): + return self.http_status(400, -1, 'uuid must be a string or null') + try: + await self.ap.llm_model_service.set_starred_model(request_context, model_uuid) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + return self.success(data={}) + + @self.route('/_/default', methods=['GET'], permission=Permission.RESOURCE_MANAGE) + async def get_default(request_context: RequestContext) -> str: + return self.success(data={'uuid': await self.ap.llm_model_service.get_default_model(request_context)}) + @self.route( '', methods=['GET'], diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 6e6be90c6..9d245b525 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -2,12 +2,10 @@ from __future__ import annotations import quart -from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError -from langbot_plugin.box.errors import BoxError - from ...authz import Permission from ...context import RequestContext from .....operation_trace import service as settings_service +from .....skill.repository import SkillRevisionConflictError from .. import group @@ -25,12 +23,7 @@ class SkillsRouterGroup(group.RouterGroup): async def list_skills(request_context: RequestContext) -> quart.Response: try: skills = await self.ap.skill_service.list_skills(request_context) - except EntitlementFeatureUnavailableError: - # Plans without managed sandbox support have no runnable skills. - # Treat that capability absence as an empty collection so the - # shared UI can render normally instead of surfacing a 500. - return self.success(data={'skills': []}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'skills': skills}) @@ -48,7 +41,7 @@ class SkillsRouterGroup(group.RouterGroup): try: skill = await self.ap.skill_service.create_skill(request_context, data) return self.success(data={'skill': skill}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) @self.route( @@ -60,7 +53,7 @@ class SkillsRouterGroup(group.RouterGroup): async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response: try: skill = await self.ap.skill_service.get_skill(request_context, skill_name) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) if not skill: return self.http_status(404, -1, 'Skill not found') @@ -80,11 +73,13 @@ class SkillsRouterGroup(group.RouterGroup): data = await quart.request.json try: previous = await self.ap.skill_service.get_skill(request_context, skill_name) - except (ValueError, BoxError): + except ValueError: previous = None try: skill = await self.ap.skill_service.update_skill(request_context, skill_name, data) - except (ValueError, BoxError) as exc: + except SkillRevisionConflictError as exc: + return self.http_status(409, -1, str(exc)) + except ValueError as exc: return self.http_status(400, -1, str(exc)) changes = settings_service.changed_fields( @@ -101,7 +96,7 @@ class SkillsRouterGroup(group.RouterGroup): try: await self.ap.skill_service.delete_skill(request_context, skill_name) return self.success() - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) @self.route( @@ -123,7 +118,9 @@ class SkillsRouterGroup(group.RouterGroup): include_hidden=include_hidden, ) return self.success(data=result) - except (ValueError, BoxError) as exc: + except SkillRevisionConflictError as exc: + return self.http_status(409, -1, str(exc)) + except ValueError as exc: return self.http_status(400, -1, str(exc)) @self.route( @@ -136,7 +133,7 @@ class SkillsRouterGroup(group.RouterGroup): try: result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path) return self.success(data=result) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) @self.route( @@ -148,13 +145,20 @@ class SkillsRouterGroup(group.RouterGroup): async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response: data = await quart.request.json content = data.get('content', '') + base_revision = str(data.get('base_revision', '') or '').strip() or None if content is None: return self.http_status(400, -1, 'Missing required field: content') try: - result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content) + result = await self.ap.skill_service.write_skill_file( + request_context, + skill_name, + path, + content, + base_revision=base_revision, + ) return self.success(data=result) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) @self.route( @@ -191,7 +195,7 @@ class SkillsRouterGroup(group.RouterGroup): try: skill = await self.ap.skill_service.install_from_github(request_context, data) return self.success(data={'skills': skill}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) except Exception: raise @@ -215,7 +219,7 @@ class SkillsRouterGroup(group.RouterGroup): try: preview = await self.ap.skill_service.preview_install_from_github(request_context, data) return self.success(data={'skills': preview}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) except Exception: raise @@ -243,7 +247,7 @@ class SkillsRouterGroup(group.RouterGroup): source_paths=form.getlist('source_paths'), ) return self.success(data={'skills': skill}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) except Exception: raise @@ -266,7 +270,7 @@ class SkillsRouterGroup(group.RouterGroup): filename=file.filename or '', ) return self.success(data={'skills': preview}) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) except Exception: raise @@ -285,5 +289,5 @@ class SkillsRouterGroup(group.RouterGroup): try: result = await self.ap.skill_service.scan_directory_async(request_context, path) return self.success(data=result) - except (ValueError, BoxError) as exc: + except ValueError as exc: return self.http_status(400, -1, str(exc)) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index ee2ee7dc9..4ad52c7d4 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -111,7 +111,11 @@ class UserRouterGroup(group.RouterGroup): async def _() -> str: if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud': return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud') - json_data = await quart.request.json + json_data = await quart.request.get_json(silent=True) + if not isinstance(json_data, dict) or not all( + isinstance(json_data.get(field), str) for field in ('user', 'password') + ): + return self.http_status(400, -1, 'Invalid request parameters') user_email = json_data['user'] password = json_data['password'] diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py index dbb9626cf..cbc886b15 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhooks.py +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -1,6 +1,7 @@ from __future__ import annotations import quart +import quart_cors import traceback from .. import group @@ -10,6 +11,35 @@ from .....utils import bounded_executor @group.group_class('webhooks', '/bots') class WebhookRouterGroup(group.RouterGroup): async def initialize(self) -> None: + @self.quart_app.websocket(self.path + '//onebot/v11/ws') + @quart_cors.cors_exempt + async def onebot_websocket(bot_uuid: str): + # Protocol clients omit Origin; bearer authentication protects this endpoint. + from .....platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter + + runtime = await self.ap.platform_mgr.resolve_public_bot(bot_uuid) + if runtime is None or not runtime.enable or not isinstance(runtime.adapter, AiocqhttpAdapter): + quart.abort(404) + adapter = runtime.adapter + socket = quart.websocket._get_current_object() + if adapter.config['connection_mode'] != 'reverse_ws': + quart.abort(409) + if not adapter.authenticate(socket.headers): + quart.abort(401) + if socket.headers.get('X-Client-Role', '').lower() != 'universal': + quart.abort(400) + self_id = socket.headers.get('X-Self-ID', '') + expected = str(adapter.config.get('self_id', '') or '') + if not self_id or (expected and self_id != expected): + quart.abort(400) + if adapter.bot.connection is not None: + quart.abort(409) + await socket.accept() + try: + await adapter.serve_connection(socket.send, socket.receive, socket.close, self_id) + except Exception: + await socket.close(1011) + @self.route('/', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) async def handle_webhook(bot_uuid: str): """处理 bot webhook 回调(无子路径)""" @@ -31,6 +61,11 @@ class WebhookRouterGroup(group.RouterGroup): 适配器返回的响应 """ try: + setup = getattr(self.ap, 'slack_setup_service', None) + if setup is not None and not path and quart.request.method == 'POST': + challenge = await setup.challenge(bot_uuid, quart.request) + if challenge is not None: + return quart.jsonify(challenge) # Public ingress never accepts X-Workspace-Id. The opaque bot UUID # is resolved against the already-bound runtime resource, which # carries the trusted Workspace and placement generation. diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 7873e9f17..4f653a2e3 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -14,6 +14,8 @@ from .....workspace.errors import WorkspaceNotFoundError from .....workspace.invitation_delivery import InvitationDeliveryService from .....operation_trace import service as settings_service from .. import group +from .....i18n import SUPPORTED_LANGUAGES +from .....i18n.workspace import get_workspace_language, set_workspace_language def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]: @@ -74,6 +76,34 @@ class WorkspacesRouterGroup(group.RouterGroup): return await operation() async def initialize(self) -> None: + @self.route( + '/current/language', + methods=['GET'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_VIEW, + ) + async def get_language(request_context: RequestContext): + return self.success( + data={ + 'language': await get_workspace_language(self.ap, request_context.workspace_uuid, refresh=True), + 'supported_languages': list(SUPPORTED_LANGUAGES), + } + ) + + @self.route( + '/current/language', + methods=['PUT'], + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, + permission=Permission.WORKSPACE_UPDATE, + ) + async def update_language(request_context: RequestContext): + body = await quart.request.get_json() + locale = body.get('language') if isinstance(body, dict) else None + if locale not in SUPPORTED_LANGUAGES: + return self.http_status(400, 'invalid_language', 'Unsupported Workspace language') + locale = await set_workspace_language(self.ap, request_context.workspace_uuid, locale) + return self.success(data={'language': locale}) + @self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN) async def _(account) -> typing.Any: """List the active Workspaces available to an authenticated Account. @@ -129,6 +159,8 @@ class WorkspacesRouterGroup(group.RouterGroup): membership = quart.g.workspace_membership workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid) plan_name: str | None = None + limitation = None + current_plan = None resolver = getattr(self.ap, 'entitlement_resolver', None) if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None: entitlement = await resolver.resolve( @@ -136,6 +168,18 @@ class WorkspacesRouterGroup(group.RouterGroup): minimum_revision=request_context.entitlement_revision, ) plan_name = entitlement.plan_name + if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None: + current_plan = { + 'name': entitlement.plan_name, + 'subscription_started_at': entitlement.subscription_started_at, + 'revision': entitlement.plan_revision, + 'monthly_bonus_credits': entitlement.monthly_bonus_credits, + 'limits': entitlement.limits, + 'entitlement_features': entitlement.features, + } + from ...service.workspace_limits import LIMIT_KEYS + + limitation = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()} if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN: return self.success( data={ @@ -154,6 +198,8 @@ class WorkspacesRouterGroup(group.RouterGroup): 'permissions': sorted(request_context.workspace.permissions), 'placement_generation': request_context.placement_generation, 'plan_name': plan_name, + 'current_plan': current_plan, + 'limitation': limitation, } ) account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid) @@ -170,6 +216,8 @@ class WorkspacesRouterGroup(group.RouterGroup): 'permissions': sorted(request_context.workspace.permissions), 'placement_generation': request_context.placement_generation, 'plan_name': plan_name, + 'current_plan': current_plan, + 'limitation': limitation, } ) diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index 260b1f2df..c0f0b34bf 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -1,5 +1,9 @@ from __future__ import annotations +from ....i18n import t + +from contextlib import aclosing + import datetime import copy import fnmatch @@ -45,7 +49,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement AGENT_KIND_AGENT = 'agent' AGENT_KIND_PIPELINE = 'pipeline' AGENT_KIND_EVENT_PROCESSOR = 'event_processor' -PIPELINE_EVENT_PATTERNS = ['message.*'] +PIPELINE_EVENT_PATTERNS = ['message.received'] AGENT_DEFAULT_EVENT_PATTERNS = ['*'] @@ -348,25 +352,45 @@ class AgentService: execution_events.append(visible_result) final_text = '' - async for output in self.ap.agent_run_orchestrator.run( - event, - binding, - adapter_context={ - '_query': execution_query, - '_execution_context': execution_context, - '_result_observer': observe_result, - }, + # A debug run has no platform event: the synthetic event envelope is the + # execution's inbound boundary, so the chain is opened here with the real + # event id and marked as a debug WebUI/API execution. The Runner inside + # then joins this chain instead of becoming a trace origin of its own. + from ....telemetry.execution import ingress as execution_ingress + + principal_type = str(getattr(getattr(context, 'principal', None), 'principal_type', '') or '') + origin = 'api' if principal_type == 'api_key' else 'webui' + with execution_ingress( + self.ap, + 'event_done', + execution_context, + event_id, + debug=True, + origin=origin, + synthetic_event=event_type, ): - output_text = self._provider_output_to_text(output) - if output_text: - final_text = output_text - output_items.append( - { - 'kind': output.__class__.__name__, - 'role': str(getattr(output, 'role', '') or ''), - 'text': output_text, - } - ) + async with aclosing( + self.ap.agent_run_orchestrator.run( + event, + binding, + adapter_context={ + '_query': execution_query, + '_execution_context': execution_context, + '_result_observer': observe_result, + }, + ) + ) as owned_stream: + async for output in owned_stream: + output_text = self._provider_output_to_text(output) + if output_text: + final_text = output_text + output_items.append( + { + 'kind': output.__class__.__name__, + 'role': str(getattr(output, 'role', '') or ''), + 'text': output_text, + } + ) return { 'event_id': event_id, @@ -410,7 +434,7 @@ class AgentService: pipeline_uuid = await self.ap.pipeline_service.create_pipeline( context, { - 'name': agent_data.get('name') or 'New Pipeline', + 'name': agent_data.get('name') or t('New Pipeline'), 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or '⚙️', 'config': {}, @@ -427,12 +451,19 @@ class AgentService: config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context) config, runner_id, _ = RunnerConfigResolver.resolve_agent_config(config) await self._validate_runner_for_agent(context, runner_id) + if runner_id: + descriptor = await self.ap.runner_registry.get(context, runner_id) + config['runner_config'][runner_id] = await self.ap.llm_model_service.apply_default_model( + context, + descriptor.config_schema, + config['runner_config'][runner_id], + ) patterns = agent_data.get('supported_event_patterns', AGENT_DEFAULT_EVENT_PATTERNS) new_uuid = str(uuid.uuid4()) values = { 'workspace_uuid': workspace_uuid, 'uuid': new_uuid, - 'name': agent_data.get('name') or 'New Agent', + 'name': agent_data.get('name') or t('New Agent'), 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or ('🧩' if kind == AGENT_KIND_EVENT_PROCESSOR else '🤖'), 'kind': kind, @@ -509,6 +540,7 @@ class AgentService: async def _prepare_event_processor(self, context, data, existing=None): """Resolve an installed component and keep its capability declaration authoritative.""" + draft = existing is None and 'config' not in data and 'parameters' not in data config = copy.deepcopy(data.get('config', existing.config if existing is not None else {})) if not isinstance(config, dict): raise ValueError('Processor configuration must be an object') @@ -537,13 +569,19 @@ class AgentService: parameters = runner_config.get(component_ref) if parameters is None: parameters = self.ap.pipeline_service._get_default_values_from_schema(descriptor.config_schema) + if draft: + parameters = await self.ap.llm_model_service.apply_default_model( + context, descriptor.config_schema, parameters + ) if not isinstance(parameters, dict): raise ValueError('Processor parameters must be an object') for field in descriptor.config_schema: - if field.get('required') and parameters.get(field['name']) in (None, ''): + if not draft and field.get('required') and parameters.get(field['name']) in (None, ''): raise ValueError(f'Required processor parameter: {field["name"]}') config['runner_config'] = {component_ref: parameters} - return config, component_ref, descriptor.supported_event_patterns + # Component-only creation keeps defaults for the detail form, but does + # not subscribe to events until configuration is saved and validated. + return config, component_ref, [] if draft else descriptor.supported_event_patterns async def get_processor_runs(self, context, processor_id, *, before_id=None): """Read only this Workspace's Agent or plugin processor runs.""" diff --git a/src/langbot/pkg/api/http/service/assistant.py b/src/langbot/pkg/api/http/service/assistant.py index 74e7073ae..033b53750 100644 --- a/src/langbot/pkg/api/http/service/assistant.py +++ b/src/langbot/pkg/api/http/service/assistant.py @@ -1,6 +1,7 @@ """In-process management assistant using the existing model and resource services.""" import asyncio +import contextlib import json import uuid @@ -44,6 +45,187 @@ class AssistantService: self.ap = ap # ponytail: per-process admission; shared quotas if multiple workers need a global ceiling. self._slots = asyncio.Semaphore(4) + self._runs = {} + + async def list_conversations(self, context): + self._scope(context, '') + result = await self.ap.persistence_mgr.execute_async( + sa.select(Conversation) + .where( + Conversation.workspace_uuid == context.workspace_uuid, + Conversation.account_uuid == context.account_uuid, + ) + .order_by(Conversation.updated_at.desc(), Conversation.uuid) + .limit(100) + ) + return [ + dict( + uuid=row['uuid'], + status=row['status'], + revision=row['revision'], + title=next( + ( + m.get('content', '')[:80] + for m in row['messages'] + if m['role'] == 'user' and isinstance(m.get('content'), str) + ), + '', + ), + ) + for row in result.mappings() + ] + + async def progress(self, context, conversation_id): + conversation = await self.get(context, conversation_id) + run = self._runs.get(conversation_id) + if run: + return {**(run['snapshot'] or self.public_view(conversation)), 'progress': run['progress']} + return self.public_view(conversation) + + def start_run(self, context, conversation_id, body): + """Own execution independently of any HTTP subscriber; never block on a slow browser.""" + if conversation_id in self._runs: + raise AssistantError('stale_turn') + if len(self._runs) >= 4: + raise AssistantError('busy', 429) + queue = asyncio.Queue(maxsize=32) + run = { + 'queue': queue, + 'snapshot': None, + 'progress': {}, + 'stopping': False, + 'revision': body.revision + 1, + 'started': asyncio.Event(), + } + self._runs[conversation_id] = run + + async def emit(frame): + if frame['kind'] == 'snapshot': + run['snapshot'] = frame['data'] + run['progress'] = {} + elif frame['kind'] == 'text': + run['progress']['text'] = frame['data']['text'] + elif frame['kind'] == 'phase': + run['progress'].update(frame['data']) + if queue.full(): + queue.get_nowait() + queue.put_nowait(frame) + + async def execute(): + run['started'].set() + tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) + scope = tenant_scope(context.workspace_uuid) if callable(tenant_scope) else contextlib.nullcontext() + try: + async with scope: + try: + conversation = await self.turn( + context, + conversation_id, + body.revision, + body.text, + body.approved, + str(body.model_uuid) if body.model_uuid else None, + on_event=emit, + ) + except asyncio.CancelledError: + conversation = await self.get(context, conversation_id) + # Preserve interrupted tool outcomes; stopping cannot undo an external write. + if run['stopping'] and ( + conversation['error'] == 'result_unknown' + or (conversation['status'] == 'running' and conversation['revision'] == run['revision']) + ): + await self._save(context, conversation, 'failed', 'stopped') + await emit({'kind': 'completed', 'data': self.public_view(conversation)}) + except AssistantError as exc: + await emit({'kind': 'error', 'code': exc.code}) + except Exception: + self.ap.logger.exception('Assistant background run failed') + await emit({'kind': 'error', 'code': 'turn_failed'}) + finally: + self._runs.pop(conversation_id, None) + + run['task'] = asyncio.create_task(execute()) + return queue + + async def stop(self, context, conversation_id, revision): + require_permission(context, Permission.RUNTIME_OPERATE) + conversation = await self.get(context, conversation_id) + run = self._runs.get(conversation_id) + if run and run['revision'] != revision: + raise AssistantError('stale_turn') + if not run: + if conversation['revision'] != revision: + raise AssistantError('stale_turn') + return self.public_view(conversation) + await run['started'].wait() + if not run['stopping']: + run['stopping'] = True + run['task'].cancel() + await asyncio.shield(run['task']) + return self.public_view(await self.get(context, conversation_id)) + + async def recommended_model(self, context): + require_permission(context, Permission.RUNTIME_OPERATE) + try: + recommended = await self.ap.space_service.get_recommended_assistant_model(context) + model = await self.ap.model_mgr.get_model_by_uuid( + ExecutionContext.from_request(context), recommended['uuid'] + ) + if 'func_call' not in (model.model_entity.abilities or []): + raise ValueError('Recommended model does not support tools') + return {'uuid': recommended['uuid'], 'name': model.model_entity.name} + except Exception as exc: + raise AssistantError('model_unavailable', 503) from exc + + async def _invoke(self, model, kwargs, on_event): + if on_event is None: + return await model.provider.invoke_llm(**kwargs) + message = {'role': 'assistant', 'content': ''} + calls = {} + finished = False + async for chunk in model.provider.invoke_llm_stream(**kwargs): + finished = finished or chunk.is_final + content = chunk.content or '' + if isinstance(content, list): + content = ''.join(item.text or '' for item in content if item.type == 'text') + if chunk.all_content is not None: + message['content'] = chunk.all_content + else: + message['content'] += content + for field in ('resp_message_id', 'provider_specific_fields'): + value = getattr(chunk, field, None) + if value is not None: + if field == 'provider_specific_fields': + message[field] = {**message.get(field, {}), **value} + else: + message[field] = value + for call in chunk.tool_calls or []: + value = call.model_dump(mode='json') + if call.id in calls: + saved = calls[call.id] + saved['function']['arguments'] += call.function.arguments or '' + if call.function.name: + saved['function']['name'] = call.function.name + if value.get('provider_specific_fields'): + saved['provider_specific_fields'] = { + **(saved.get('provider_specific_fields') or {}), + **value['provider_specific_fields'], + } + else: + calls[call.id] = value + if len(calls) > 8 or len(json.dumps(message, ensure_ascii=False)) + len(json.dumps(calls)) > 64000: + raise AssistantError('response_too_large') + if content or chunk.all_content is not None: + await on_event({'kind': 'text', 'data': {'text': message['content']}}) + if not finished: + raise AssistantError('stream_interrupted') + if calls: + message['tool_calls'] = list(calls.values()) + return Message.model_validate(message) + + async def _snapshot(self, conversation, on_event): + if on_event is not None: + await on_event({'kind': 'snapshot', 'data': self.public_view(conversation)}) @staticmethod def _scope(context, conversation_id): @@ -150,7 +332,7 @@ class AssistantService: raise AssistantError('stale_turn') conversation.update(status=status, error=error) - async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None): + async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None, on_event=None): require_permission(context, Permission.RUNTIME_OPERATE) if model_uuid is not None and text is None: raise AssistantError('invalid_input', 400) @@ -201,7 +383,7 @@ class AssistantService: await self._save(context, conversation, 'running') try: if not conversation['model_uuid']: - recommended = await self.ap.space_service.get_recommended_chat_model(context) + recommended = await self.recommended_model(context) conversation['model_uuid'] = recommended['uuid'] execution = ExecutionContext.from_request(context) model = selected_model or await self.ap.model_mgr.get_model_by_uuid( @@ -215,22 +397,30 @@ class AssistantService: return conversation if approved is not None: calls = self._calls(conversation['messages'][-1]) - await self._execute(context, conversation, calls, approved) - for _ in range(8): - response = await model.provider.invoke_llm( - query=None, - model=model, - messages=[Message(role='system', content=SYSTEM_PROMPT)] - + [Message.model_validate(message) for message in conversation['messages']], - funcs=tool_definitions(context), - extra_args=model.model_entity.extra_args or {}, - remove_think=True, - execution_context=execution, + await self._execute(context, conversation, calls, approved, on_event) + for round_index in range(8): + await self._snapshot(conversation, on_event) + if on_event is not None: + await on_event({'kind': 'phase', 'data': {'phase': 'thinking', 'round': round_index + 1}}) + response = await self._invoke( + model, + dict( + query=None, + model=model, + messages=[Message(role='system', content=SYSTEM_PROMPT)] + + [Message.model_validate(message) for message in conversation['messages']], + funcs=tool_definitions(context), + extra_args=model.model_entity.extra_args or {}, + remove_think=True, + execution_context=execution, + ), + on_event, ) message = response.model_dump(mode='json') if len(json.dumps(message, ensure_ascii=False)) > 64000: raise AssistantError('response_too_large') conversation['messages'].append(message) + await self._snapshot(conversation, on_event) calls = self._calls(message) if not calls: await self._save(context, conversation, 'ready') @@ -249,9 +439,12 @@ class AssistantService: if any(TOOLS[call['function']['name']][2] for call in calls): await self._save(context, conversation, 'approval') return conversation - await self._execute(context, conversation, calls, True) + await self._execute(context, conversation, calls, True, on_event) await self._save(context, conversation, 'failed', 'round_limit') except asyncio.CancelledError: + partial = self._runs.get(conversation_id, {}).get('progress', {}).get('text') + if partial: + conversation['messages'].append(Message(role='assistant', content=partial).model_dump(mode='json')) await asyncio.shield(self._save(context, conversation, 'failed', 'result_unknown')) raise except TimeoutError: @@ -280,7 +473,7 @@ class AssistantService: ).model_dump(mode='json') ) - async def _execute(self, context, conversation, calls, approved): + async def _execute(self, context, conversation, calls, approved, on_event=None): # Validate the complete saved batch before any write, including after approval. if approved: for call in calls: @@ -293,6 +486,19 @@ class AssistantService: else: # Persist before effects. A crash leaves a running/unknown operation, never a replayable approval. await self._save(context, conversation, 'running') + if on_event is not None: + await on_event( + { + 'kind': 'phase', + 'data': { + 'phase': 'tool', + 'tool': { + 'name': func['name'], + 'arguments': redact_secrets(json.loads(func['arguments'] or '{}')), + }, + }, + } + ) try: result = await execute_tool(self.ap, context, func['name'], json.loads(func['arguments'] or '{}')) except Exception as exc: @@ -308,3 +514,4 @@ class AssistantService: raise AssistantError('tool_failed') self._append_result(conversation, call, result) await self._save(context, conversation, 'running') + await self._snapshot(conversation, on_event) diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index a850f428e..fd9599f64 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -312,12 +312,35 @@ async def execute_tool(ap, context, name: str, arguments: dict): if await ap.knowledge_service.get_knowledge_base(context, kb_id) is None: raise ValueError('Knowledge base not found') config = copy.deepcopy(pipeline['config']) - config['ai']['runner']['runner'] = 'local-agent' - local = config['ai']['local-agent'] - local['model']['primary'] = args['model_uuid'] + runner_id = 'plugin:langbot-team/LocalAgent/default' + ai = config.get('ai') if isinstance(config.get('ai'), dict) else {} + runner = ai.get('runner') if isinstance(ai.get('runner'), dict) else {} + runner_configs = ai.get('runner_config') if isinstance(ai.get('runner_config'), dict) else {} + local = runner_configs.get(runner_id) if isinstance(runner_configs.get(runner_id), dict) else {} + model = local.get('model') if isinstance(local.get('model'), dict) else {} + local['model'] = { + 'primary': args['model_uuid'], + 'fallbacks': model.get('fallbacks') if isinstance(model.get('fallbacks'), list) else [], + 'reasoning': model.get('reasoning') if isinstance(model.get('reasoning'), dict) else {}, + } local['prompt'] = [{'role': 'system', 'content': args['system_prompt']}] local['knowledge-bases'] = args['knowledge_base_uuids'] - await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], {'config': config}) + # 4.11 shape: ai.runner.id + ai.runner_config[id]; legacy keys are dropped. + ai['runner'] = {'id': runner_id, 'expire-time': runner.get('expire-time', 0)} + ai['runner_config'] = {**runner_configs, runner_id: local} + ai.pop('local-agent', None) + config['ai'] = ai + update = {'config': config} + prefs = pipeline.get('extensions_preferences') + if isinstance(prefs, dict) and prefs.get('enable_all_plugins') is False: + plugins = prefs.get('plugins') if isinstance(prefs.get('plugins'), list) else [] + entry = {'author': 'langbot-team', 'name': 'LocalAgent'} + if not any( + isinstance(p, dict) and p.get('author') == entry['author'] and p.get('name') == entry['name'] + for p in plugins + ): + update['extensions_preferences'] = {**prefs, 'plugins': [*plugins, entry]} + await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], update) return {'uuid': args['pipeline_uuid'], 'url': f'/home/pipelines?id={args["pipeline_uuid"]}'} resource_id = await ap.knowledge_service.create_knowledge_base(context, args) return {'uuid': resource_id, 'url': f'/home/knowledge?id={resource_id}'} diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index ef0c680ed..766adbc87 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement from ....utils import httpclient from ....platform.sources import http_bot_signing from ....platform.adapter_names import canonical_adapter_name +from ....cloud.plan_policy import require_workspace_platform from ....agent.runner.errors import RunnerNotFoundError @@ -52,19 +53,19 @@ class BotService: return None def _adapter_declares_webhook_url(self, adapter_name: str) -> bool: - """Whether the adapter manifest declares a generated webhook URL config item.""" + """Whether the manifest needs a generated public bot ingress URL.""" component = self._get_adapter_component(adapter_name) if component is None: return False for config_item in component.spec.get('config', []): - if config_item.get('type') == 'webhook-url': + if config_item.get('type') == 'webhook-url' or config_item.get('name') == '__system.onebot_ws_url': return True return False @staticmethod def _is_message_event_pattern(event_pattern: str) -> bool: - return event_pattern == 'message.*' or event_pattern.startswith('message.') + return event_pattern == 'message.received' @staticmethod def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool: @@ -507,7 +508,7 @@ class BotService: if target_type == 'pipeline': if not self._is_message_event_pattern(event_pattern): - raise ValueError('Pipeline can only be bound to message events') + raise ValueError('Pipeline can only be bound to message.received') result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where( @@ -685,7 +686,9 @@ class BotService: """Create bot""" workspace_uuid = require_workspace_uuid(context) # Check limitation - limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + from .workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, workspace_uuid) max_bots = limitation.get('max_bots', -1) if max_bots >= 0: existing_bots = await self.get_bots(context) @@ -694,6 +697,7 @@ class BotService: # TODO: 检查配置信息格式 bot_data = await self._prepare_bot_data(context, bot_data, include_uuid=True) + await require_workspace_platform(self.ap, workspace_uuid, bot_data['adapter']) bot_data['uuid'] = str(uuid.uuid4()) bot_data['workspace_uuid'] = workspace_uuid bot_data.setdefault('event_bindings', []) @@ -704,7 +708,8 @@ class BotService: bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) try: - await self.ap.platform_mgr.load_bot(context, bot) + if bot.get('enable'): + await self.ap.platform_mgr.load_bot(context, bot) except Exception as exc: raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc @@ -715,6 +720,9 @@ class BotService: workspace_uuid = require_workspace_uuid(context) update_data = await self._prepare_bot_data(context, bot_data, include_uuid=False) update_data.pop('workspace_uuid', None) + if 'adapter' in update_data or update_data.get('enable'): + current = await self.get_bot(context, bot_uuid, include_secret=True) + await require_workspace_platform(self.ap, workspace_uuid, update_data.get('adapter', current['adapter'])) result = await self.ap.persistence_mgr.execute_async( scope_statement( @@ -740,8 +748,8 @@ class BotService: bot = await self.get_bot(context, bot_uuid, include_secret=True) try: await self.ap.platform_mgr.remove_bot(context, bot_uuid) - runtime_bot = await self.ap.platform_mgr.load_bot(context, bot) - if runtime_bot.enable: + if bot.get('enable'): + runtime_bot = await self.ap.platform_mgr.load_bot(context, bot) await runtime_bot.run() except Exception as exc: raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc @@ -775,7 +783,8 @@ class BotService: raise WorkspaceNotFoundError('Bot not found') runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid) if runtime_bot is None: - raise Exception('Bot not found') + # Saved but disabled/unconfigured bots have no runtime logs yet. + return [], 0 logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count) diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py index 654bf2f17..e85d16fd8 100644 --- a/src/langbot/pkg/api/http/service/knowledge.py +++ b/src/langbot/pkg/api/http/service/knowledge.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import sqlalchemy from ....api.http.authz import WorkspaceRequiredError @@ -56,14 +58,20 @@ class KnowledgeService: require_workspace_uuid(context) # In new architecture, we delegate entirely to RAGManager which uses plugins. # Legacy internal KB creation is removed. - limitation = ( - getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {}) - ) + from .workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, require_workspace_uuid(context)) max_knowledge_bases = limitation.get('max_knowledge_bases', -1) if max_knowledge_bases >= 0: knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context) if len(knowledge_bases) >= max_knowledge_bases: - raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached') + raise ValueError( + t( + 'Maximum number of {resource} ({limit}) reached', + resource=t('knowledge bases'), + limit=max_knowledge_bases, + ) + ) knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id') if not knowledge_engine_plugin_id: diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py index cdaf32091..86485efa7 100644 --- a/src/langbot/pkg/api/http/service/maintenance.py +++ b/src/langbot/pkg/api/http/service/maintenance.py @@ -114,6 +114,10 @@ class MaintenanceService: } if media_cache is not None and is_singleton: result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0) + if media_cache is not None: + from ....storage.inline_media_cleanup import cleanup_inline_media + + result['inline_media_records'] = await cleanup_inline_media(self.ap, context, media_retention_days) return result async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]: @@ -148,6 +152,10 @@ class MaintenanceService: scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context) roots = [('storage', scoped_storage_path)] + skill_repository = getattr(self.ap, 'skill_repository', None) + if skill_repository is not None: + roots.append(('skills', Path(skill_repository.storage_root(context)))) + sections = await asyncio.to_thread(self._collect_sections, roots) runtime_processes = await self._runtime_storage_processes( context, diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 67e8e14ae..3e5eb7339 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import copy import re import uuid @@ -212,7 +214,9 @@ class MCPService: # stdio MCP even though Box Runtime itself remains available. require_stdio_mcp_enabled(self.ap, server_data) - limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + from .workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, workspace_uuid) max_extensions = limitation.get('max_extensions', -1) if max_extensions >= 0: mcp_count_result = await self.ap.persistence_mgr.execute_async( @@ -226,7 +230,9 @@ class MCPService: .where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid) ) if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions: - raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + raise ValueError( + t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions) + ) payload = dict(server_data) payload.pop('workspace_uuid', None) @@ -244,7 +250,7 @@ class MCPService: ) ) if existing_result.first() is not None: - raise ValueError(f'MCP server already exists: {server_name}') + raise ValueError(t('MCP server already exists: {name}', name=server_name)) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload)) created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid']) @@ -330,7 +336,7 @@ class MCPService: raise ValueError('MCP server name is required') duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name']) if duplicate is not None and duplicate['uuid'] != server_uuid: - raise ValueError(f'MCP server already exists: {payload["name"]}') + raise ValueError(t('MCP server already exists: {name}', name=payload['name'])) effective_server = {**old_server, **payload} # Existing disabled rows remain readable/deletable. Switching away diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py index bf8c6b196..bfd20bfaa 100644 --- a/src/langbot/pkg/api/http/service/model.py +++ b/src/langbot/pkg/api/http/service/model.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import uuid import sqlalchemy @@ -146,7 +148,13 @@ async def _validate_provider_supports( 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') + raise ValueError( + t( + 'Provider {provider} does not support {model_type} models', + provider=requester_name, + model_type=model_type, + ) + ) async def _require_workspace_provider( @@ -196,6 +204,91 @@ class LLMModelsService: def __init__(self, ap: app.Application) -> None: self.ap = ap + async def get_starred_model(self, context: TenantContext) -> str | None: + from ....entity.persistence.metadata import WorkspaceMetadata + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == require_workspace_uuid(context), + WorkspaceMetadata.key == 'starred_llm_model', + ) + ) + row = result.first() + model_uuid = row[0] if row else None + if model_uuid and await self.get_llm_model(context, model_uuid) is not None: + return model_uuid + return None + + async def set_starred_model(self, context: TenantContext, model_uuid: str | None) -> None: + from ....entity.persistence.metadata import WorkspaceMetadata + + workspace_uuid = require_workspace_uuid(context) + if model_uuid and await self.get_llm_model(context, model_uuid) is None: + raise ValueError('Model not found in this Workspace') + # A single metadata row makes concurrent selections replace each other. + dialect = self.ap.persistence_mgr.get_db_engine().dialect.name + if dialect == 'postgresql': + from sqlalchemy.dialects.postgresql import insert + else: + from sqlalchemy.dialects.sqlite import insert + statement = insert(WorkspaceMetadata).values( + workspace_uuid=workspace_uuid, + key='starred_llm_model', + value=model_uuid or '', + ) + await self.ap.persistence_mgr.execute_async( + statement.on_conflict_do_update( + index_elements=['workspace_uuid', 'key'], + set_={'value': model_uuid or ''}, + ) + ) + + async def get_default_model(self, context: TenantContext) -> str | None: + starred = await self.get_starred_model(context) + if starred: + return starred + if self.ap.instance_config.data.get('space', {}).get('disable_models_service', False): + return None + owner = await self.ap.user_service.get_workspace_owner(require_workspace_uuid(context)) + cloud = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + if not cloud and not (owner and owner.space_account_uuid): + return None + try: + return (await self.ap.space_service.get_recommended_chat_model(context))['uuid'] + except Exception: + return None + + async def apply_default_model(self, context: TenantContext, schema: list[dict], parameters: dict) -> dict: + import copy + + fields = [ + field + for field in schema + if field.get('type') + in ( + 'llm-model-selector', + 'select-llm-model', + 'model-fallback-selector', + ) + and field.get('name') + ] + if not fields: + return parameters + model_uuid = await self.get_default_model(context) + if not model_uuid: + return parameters + result = copy.deepcopy(parameters) + for field in fields: + name = field['name'] + if field['type'] == 'model-fallback-selector': + value = result.get(name) + value = value if isinstance(value, dict) else {} + if not value.get('primary'): + result[name] = {**value, 'primary': model_uuid, 'fallbacks': value.get('fallbacks', [])} + elif not result.get(name): + result[name] = model_uuid + return result + async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]: """Get all LLM models with provider info""" result = await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index 0bae983a6..0480a7d1b 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -10,10 +10,16 @@ from sqlalchemy.dialects import postgresql as postgresql_dialect from sqlalchemy.dialects import sqlite as sqlite_dialect from ....core import app +from ....entity.persistence import agent as persistence_agent from ....entity.persistence import monitoring as persistence_monitoring +from ....entity.persistence import agent_run as persistence_agent_run +from ....entity.persistence import agent_interaction as persistence_agent_interaction +from ....entity.persistence.event_log import EventLog +from ....storage.media import MediaCache from ..authz import WorkspaceRequiredError from ..context import ExecutionContext from .tenant import TenantContext, require_workspace_uuid +from .monitoring_execution_details import ExecutionDetailsMixin _DEFAULT_MONITORING_PAGE_ROWS = 1000 @@ -29,6 +35,76 @@ _HARD_MAX_MONITORING_OFFSET = 10000000 _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4 _HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100 +# Canonical execution status groups shared by the agent ledger and pipeline query +# records so both object types can be summarised with one vocabulary. +_AGENT_STATUS_GROUP = { + 'completed': 'completed', + 'failed': 'failed', + 'timeout': 'failed', + 'cancelled': 'cancelled', + 'created': 'queued', + 'queued': 'queued', + 'claimed': 'running', + 'running': 'running', +} +_PIPELINE_STATUS_GROUP = { + 'success': 'completed', + 'error': 'failed', + 'pending': 'running', + 'discarded': 'ignored', + 'not_matched': 'ignored', +} + + +def _status_group(status: str, source: str) -> str: + table = _AGENT_STATUS_GROUP if source == 'agent' else _PIPELINE_STATUS_GROUP + return table.get(status, status) + + +def _epoch_ms(value: datetime.datetime | None) -> int | None: + """Epoch milliseconds for a DB timestamp, treating naive values as UTC.""" + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + return int(round(value.timestamp() * 1000)) + + +def _percentile_ms(values: list[float], fraction: float) -> int | None: + """Nearest-rank percentile (ms) over a non-empty, prefix-sorted list.""" + if not values: + return None + rank = max(1, min(len(values), int(round(fraction * len(values))))) + return int(round(values[rank - 1])) + + +def _collect_text(node, out: list[str], depth: int = 0) -> None: + """Best-effort extraction of human text from a serialised message chain.""" + if depth > 6: + return + if isinstance(node, dict): + for key, value in node.items(): + if key == 'text' and isinstance(value, str): + out.append(value) + elif isinstance(value, (dict, list)): + _collect_text(value, out, depth + 1) + elif isinstance(node, list): + for item in node: + _collect_text(item, out, depth + 1) + + +def _message_preview(raw: str | None, limit: int = 160) -> str: + if not raw: + return '' + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return raw[:limit] + parts: list[str] = [] + _collect_text(parsed, parts) + text = ' '.join(part.strip() for part in parts if part.strip()) + return (text or raw)[:limit] + def _normalize_user_id(value: str | int | None) -> str | None: """Convert numeric platform IDs before binding a VARCHAR with asyncpg. @@ -52,13 +128,19 @@ def _workspace_transaction(method): tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) if callable(tenant_uow): async with tenant_uow(workspace_uuid): - return await method(self, context, *args, **kwargs) - return await method(self, context, *args, **kwargs) + result = await method(self, context, *args, **kwargs) + else: + result = await method(self, context, *args, **kwargs) + if method.__name__ in {'record_message', 'update_message_status'}: + from ....utils.inflight import inflight_hub + + inflight_hub.notify(workspace_uuid) + return result return wrapped -class MonitoringService: +class MonitoringService(ExecutionDetailsMixin): """Monitoring service""" ap: app.Application @@ -179,6 +261,9 @@ class MonitoringService: A dict mapping table name to the number of deleted rows. """ workspace_uuid = self._require_write_context(context) + from ....cloud.plan_policy import workspace_history_retention_days + + retention_days = await workspace_history_retention_days(self.ap, workspace_uuid, retention_days) if retention_days < 1: raise ValueError('retention_days must be >= 1') if batch_size < 1: @@ -420,30 +505,31 @@ class MonitoringService: def _sanitize_message_content(self, content: str) -> str: """Strip raw base64 data to protect database storage from unbounded bloating.""" - if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content): + if not content or ('base64' not in content and 'data:image/' not in content): return content try: data = json.loads(content) - - def _strip_node(node): - if isinstance(node, list): - return [_strip_node(x) for x in node] - if isinstance(node, dict): - res = dict(node) - if res.get('type') == 'Image' and res.get('base64'): - res['base64'] = None - for k, v in list(res.items()): - if isinstance(v, (list, dict)): - res[k] = _strip_node(v) - return res - return node - - return json.dumps(_strip_node(data), ensure_ascii=False) + return json.dumps(MediaCache.redact_inline_images(data), ensure_ascii=False) except Exception: return re.sub( r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content ) + async def _externalize_message_content(self, content: str | list | dict) -> str: + """Externalize structured images before allocating their JSON representation.""" + if isinstance(content, str): + if not content or ('base64' not in content and 'data:image/' not in content): + return content + try: + payload = json.loads(content) + except (TypeError, ValueError): + return self._sanitize_message_content(content) + else: + payload = content + if MediaCache.has_inline_images(payload): + payload = await self.ap.storage_mgr.media_cache.externalize_chain_dump(payload) + return json.dumps(MediaCache.redact_inline_images(payload), ensure_ascii=False) + @_workspace_transaction async def record_message( self, @@ -452,7 +538,7 @@ class MonitoringService: bot_name: str, pipeline_id: str, pipeline_name: str, - message_content: str, + message_content: str | list | dict, session_id: str, status: str = 'success', level: str = 'info', @@ -462,12 +548,23 @@ class MonitoringService: runner_name: str | None = None, variables: str | None = None, role: str = 'user', + event_id: str | None = None, + run_id: str | None = None, + parent_message_id: str | None = None, ) -> str: """Record a message""" workspace_uuid = self._require_write_context(context) + from ....telemetry import trace + + trace_state = trace.current() + event_id = event_id or (trace_state.event_id if trace_state else None) + run_id = run_id or trace.current_run() or None message_id = str(uuid.uuid4()) - message_content = self._sanitize_message_content(message_content) + message_content = await self._externalize_message_content(message_content) + from ....utils.execution_lease import host_execution_lease + message_data = { + **(host_execution_lease() if status == 'pending' else {}), 'id': message_id, 'workspace_uuid': workspace_uuid, 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), @@ -483,8 +580,11 @@ class MonitoringService: 'user_id': _normalize_user_id(user_id), 'user_name': user_name, 'runner_name': runner_name, - 'variables': variables, + 'variables': self._sanitize_message_content(variables) if variables is not None else None, 'role': role, + 'event_id': event_id, + 'run_id': run_id, + 'parent_message_id': parent_message_id, } await self.ap.persistence_mgr.execute_async( @@ -510,6 +610,7 @@ class MonitoringService: cost: float | None = None, error_message: str | None = None, message_id: str | None = None, + details: dict | None = None, ) -> str: """Record an LLM call""" workspace_uuid = self._require_write_context(context) @@ -521,7 +622,7 @@ class MonitoringService: 'model_name': model_name, 'input_tokens': input_tokens, 'output_tokens': output_tokens, - 'total_tokens': input_tokens + output_tokens, + 'total_tokens': ((details or {}).get('usage') or {}).get('total_tokens') or (input_tokens + output_tokens), 'duration': duration, 'cost': cost, 'status': status, @@ -532,6 +633,7 @@ class MonitoringService: 'session_id': session_id, 'error_message': error_message, 'message_id': message_id, + 'details': details, } await self.ap.persistence_mgr.execute_async( @@ -786,7 +888,7 @@ class MonitoringService: if level is not None: update_values['level'] = level if variables is not None: - update_values['variables'] = variables + update_values['variables'] = self._sanitize_message_content(variables) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_monitoring.MonitoringMessage) @@ -806,6 +908,8 @@ class MonitoringService: pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> dict: """Get overview metrics""" workspace_uuid = require_workspace_uuid(context) @@ -815,15 +919,47 @@ class MonitoringService: embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid] session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid] + message_conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode + ) + ) + llm_conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode + ) + ) + session_conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode + ) + ) + embedding_conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: - message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) - llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) - session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + message_conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids + ) + ) + llm_conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids + ) + ) + session_conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids + ) + ) if start_time: message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) @@ -885,6 +1021,10 @@ class MonitoringService: active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query) active_sessions = active_sessions_result.scalar() or 0 + # Execution-first KPI values (runs + pipeline queries, latency, token + # coverage) are served by `get_executions`, which applies the execution + # view filters. This endpoint stays the message/traffic rollup so the + # dashboard does not compute the same rollup twice per page load. return { 'total_messages': total_messages, 'llm_calls': llm_calls, @@ -894,6 +1034,798 @@ class MonitoringService: 'active_sessions': active_sessions, } + # ========== Execution view ========== + + @staticmethod + def _debug_run_condition(model): + return sqlalchemy.or_( + model.binding_id.like('debug:%'), + model.conversation_id.like('debug:%'), + model.bot_id == 'websocket-proxy-bot', + ) + + @staticmethod + def _debug_pipeline_condition(model): + # Dashboard/API pipeline debug uses this reserved proxy identity. + # Real Web Page bots have their own bot UUID, even on the same adapter. + return sqlalchemy.func.coalesce(model.bot_id == 'websocket-proxy-bot', False) + + def _monitoring_execution_condition(self, model, workspace_uuid, statuses=None, mode='all'): + """Filter telemetry by its owning execution, not by the call's outcome.""" + if not statuses and mode == 'all': + return sqlalchemy.true() + Run = persistence_agent_run.AgentRun + Message = persistence_monitoring.MonitoringMessage + roots = sqlalchemy.orm.aliased(Message) + run_conditions = [Run.workspace_id == workspace_uuid] + if statuses: + run_conditions.append(Run.status.in_(self._raw_agent_statuses(statuses))) + debug = self._debug_run_condition(Run) + if mode != 'all': + run_conditions.append(debug if mode == 'debug' else sqlalchemy.not_(sqlalchemy.func.coalesce(debug, False))) + runs = sqlalchemy.select(Run.run_id).where(*run_conditions) + root_conditions = [roots.workspace_uuid == workspace_uuid, roots.role != 'assistant'] + if statuses: + root_conditions.append(roots.status.in_(self._raw_pipeline_statuses(statuses))) + if mode != 'all': + root_debug = self._debug_pipeline_condition(roots) + root_conditions.append(root_debug if mode == 'debug' else sqlalchemy.not_(root_debug)) + root_ids = sqlalchemy.select(roots.id).where(*root_conditions) + linked = sqlalchemy.or_( + Message.id.in_(root_ids), Message.parent_message_id.in_(root_ids), Message.run_id.in_(runs) + ) + message_ids = sqlalchemy.select(Message.id).where(Message.workspace_uuid == workspace_uuid, linked) + if model is Message: + return linked + if hasattr(model, 'message_id'): + return sqlalchemy.or_(model.message_id.in_(runs), model.message_id.in_(message_ids)) + if model is persistence_monitoring.MonitoringSession: + return sqlalchemy.or_( + model.session_id.in_(sqlalchemy.select(roots.session_id).where(*root_conditions)), + model.session_id.in_(sqlalchemy.select(Run.conversation_id).where(*run_conditions)), + ) + return sqlalchemy.false() + + def _processor_runs(self, workspace_uuid, processor_ids): + Run = persistence_agent_run.AgentRun + return sqlalchemy.select(Run.run_id).where( + Run.workspace_id == workspace_uuid, + sqlalchemy.func.coalesce(self._execution_json_text(Run.metadata_json, 'processor_id'), Run.agent_id).in_( + processor_ids + ), + ) + + def _monitoring_processor_condition(self, model, workspace_uuid, processor_ids): + direct = model.pipeline_id.in_(processor_ids) + if hasattr(model, 'message_id'): + Message = persistence_monitoring.MonitoringMessage + return sqlalchemy.or_( + direct, + model.message_id.in_(self._processor_runs(workspace_uuid, processor_ids)), + model.message_id.in_( + sqlalchemy.select(Message.id).where( + Message.workspace_uuid == workspace_uuid, Message.pipeline_id.in_(processor_ids) + ) + ), + ) + return direct + + def _agent_run_conditions( + self, + workspace_uuid: str, + *, + bot_ids: list[str] | None = None, + agent_ids: list[str] | None = None, + processor_ids: list[str] | None = None, + statuses: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + debug: bool | None = None, + ) -> list: + AgentRun = persistence_agent_run.AgentRun + conditions = [ + AgentRun.workspace_id == workspace_uuid, + ~sqlalchemy.exists( + sqlalchemy.select(persistence_monitoring.MonitoringMessage.id).where( + persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid, + persistence_monitoring.MonitoringMessage.run_id == AgentRun.run_id, + persistence_monitoring.MonitoringMessage.role != 'assistant', + ) + ), + ] + if bot_ids: + conditions.append(AgentRun.bot_id.in_(bot_ids)) + if agent_ids: + conditions.append(AgentRun.agent_id.in_(agent_ids)) + if processor_ids: + conditions.append(AgentRun.run_id.in_(self._processor_runs(workspace_uuid, processor_ids))) + if statuses is not None: + conditions.append(AgentRun.status.in_(statuses) if statuses else sqlalchemy.false()) + if start_time: + conditions.append(AgentRun.created_at >= start_time) + if end_time: + conditions.append(AgentRun.created_at <= end_time) + if debug is not None: + debug_condition = self._debug_run_condition(AgentRun) + conditions.append( + debug_condition if debug else sqlalchemy.not_(sqlalchemy.func.coalesce(debug_condition, False)) + ) + return conditions + + def _pipeline_execution_conditions( + self, + workspace_uuid: str, + *, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + statuses: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + debug: bool | None = None, + ) -> list: + MonitoringMessage = persistence_monitoring.MonitoringMessage + conditions = [ + MonitoringMessage.workspace_uuid == workspace_uuid, + # Assistant replies are outputs of a query, not an execution of their own. + sqlalchemy.or_(MonitoringMessage.role.is_(None), MonitoringMessage.role != 'assistant'), + ] + if bot_ids: + conditions.append(MonitoringMessage.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(self._monitoring_processor_condition(MonitoringMessage, workspace_uuid, pipeline_ids)) + if statuses is not None: + conditions.append(MonitoringMessage.status.in_(statuses) if statuses else sqlalchemy.false()) + if start_time: + conditions.append(MonitoringMessage.timestamp >= start_time) + if end_time: + conditions.append(MonitoringMessage.timestamp <= end_time) + if debug is not None: + debug_condition = self._debug_pipeline_condition(MonitoringMessage) + conditions.append(debug_condition if debug else sqlalchemy.not_(debug_condition)) + return conditions + + @staticmethod + def _raw_agent_statuses(groups: list[str] | None) -> list[str] | None: + """Raw agent statuses for a group filter; empty means "no group applies".""" + if not groups: + return None + raw: list[str] = [] + for group in groups: + raw.extend(status for status, mapped in _AGENT_STATUS_GROUP.items() if mapped == group) + return raw + + @staticmethod + def _raw_pipeline_statuses(groups: list[str] | None) -> list[str] | None: + """Raw pipeline statuses for a group filter; empty means "no group applies".""" + if not groups: + return None + raw: list[str] = [] + for group in groups: + raw.extend(status for status, mapped in _PIPELINE_STATUS_GROUP.items() if mapped == group) + return raw + + @staticmethod + def _model_rows(result) -> list: + """Rows of one ``select(Model)`` executed through ``execute_async``. + + The Core keeps the connection result contract, where ``select(Model)`` + yields a *flat* row of the model's columns (``row.uuid``, ``row.status``) + rather than a one-item ORM row. ``.scalars()`` would therefore return the + first column - the id - instead of the row, so the rows are taken as-is. + """ + return list(result.all()) + + @staticmethod + def _deserialize_json(raw: str | None) -> dict: + if not raw: + return {} + try: + value = json.loads(raw) + except (TypeError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + async def _agent_names(self, workspace_uuid: str, rows: list) -> dict[str, str]: + """The processor name of every Agent run in one page, in one query. + + A run stores the Agent it executed as a uuid; the list has to name the + processor, so the names are resolved for the whole page at once. + """ + agent_ids = {self._deserialize_json(row.metadata_json).get('processor_id') or row.agent_id for row in rows} - { + None, + '', + } + if not agent_ids: + return {} + Agent = persistence_agent.Agent + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Agent.uuid, Agent.name).where( + sqlalchemy.and_( + Agent.workspace_uuid == workspace_uuid, + Agent.uuid.in_(agent_ids), + ) + ) + ) + return {row.uuid: row.name for row in self._model_rows(result)} + + def _serialize_agent_execution(self, row, agent_names: dict[str, str] | None = None) -> dict: + metadata = self._deserialize_json(row.metadata_json) + duration_ms = None + if row.started_at and row.finished_at: + duration_ms = int(round((row.finished_at - row.started_at).total_seconds() * 1000)) + created_at_ms = _epoch_ms(row.created_at) + started_at_ms = _epoch_ms(row.started_at) + finished_at_ms = _epoch_ms(row.finished_at) + is_debug = ( + str(row.binding_id or '').startswith('debug:') + or str(row.conversation_id or '').startswith('debug:') + or row.bot_id == 'websocket-proxy-bot' + ) + return { + 'source': 'agent', + 'id': row.run_id, + 'event_id': row.event_id, + 'status': row.status, + 'status_group': _status_group(row.status, 'agent'), + 'title': metadata.get('event_type') or row.queue_name or row.runner_id, + 'target_kind': metadata.get('processor_type') or ('agent' if row.agent_id else 'processor'), + 'target_id': metadata.get('processor_id') or row.agent_id or row.binding_id, + 'target_name': metadata.get('agent_name') + or metadata.get('target_name') + or (agent_names or {}).get(metadata.get('processor_id') or row.agent_id or ''), + 'bot_id': row.bot_id, + 'bot_name': metadata.get('bot_name'), + 'pipeline_id': None, + 'pipeline_name': None, + 'runner_id': row.runner_id, + 'conversation_id': row.conversation_id, + 'session_id': row.conversation_id, + 'created_at_ms': created_at_ms, + 'started_at_ms': started_at_ms, + 'finished_at_ms': finished_at_ms, + 'duration_ms': duration_ms, + 'usage': self._deserialize_json(row.usage_json) or None, + 'cost': self._deserialize_json(row.cost_json) or None, + 'queue_name': row.queue_name, + 'debug': is_debug, + 'has_error': row.status in {'failed', 'timeout'}, + 'status_reason': row.status_reason, + 'input_preview': _message_preview(json.dumps(metadata.get('input') or {}, ensure_ascii=False), 200), + } + + def _serialize_pipeline_execution(self, row, *, summary_only=False) -> dict: + created_at_ms = _epoch_ms(row.timestamp) + preview = '' if summary_only else _message_preview(row.message_content) + return { + 'source': 'pipeline', + 'id': row.id, + 'event_id': row.event_id, + 'status': row.status, + 'status_group': _status_group(row.status, 'pipeline'), + 'title': row.pipeline_name if summary_only else preview, + 'target_kind': 'pipeline', + 'target_id': row.pipeline_id, + 'target_name': row.pipeline_name, + 'bot_id': row.bot_id, + 'bot_name': row.bot_name, + 'pipeline_id': row.pipeline_id, + 'pipeline_name': row.pipeline_name, + 'runner_id': row.runner_name, + 'conversation_id': None, + 'session_id': row.session_id, + 'created_at_ms': created_at_ms, + 'started_at_ms': None, + 'finished_at_ms': None, + 'duration_ms': None, + 'usage': None, + 'cost': None, + 'queue_name': None, + 'debug': row.bot_id == 'websocket-proxy-bot', + 'has_error': row.status == 'error', + 'input_preview': preview, + 'platform': row.platform, + 'user_id': row.user_id, + 'user_name': row.user_name, + } + + async def _get_token_coverage( + self, + workspace_uuid: str, + *, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + execution_statuses: list[str] | None = None, + mode: str = 'all', + ) -> dict: + LLMCall = persistence_monitoring.MonitoringLLMCall + conditions = [LLMCall.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode + ) + ) + if bot_ids: + conditions.append(LLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids)) + if start_time: + conditions.append(LLMCall.timestamp >= start_time) + if end_time: + conditions.append(LLMCall.timestamp <= end_time) + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select( + sqlalchemy.func.count(LLMCall.id), + sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0), + sqlalchemy.func.sum(sqlalchemy.case((LLMCall.total_tokens > 0, 1), else_=0)), + ).where(sqlalchemy.and_(*conditions)) + ) + row = result.first() + calls, total, calls_with_usage = row if row else (0, 0, 0) + return { + 'total_tokens': int(total or 0), + 'calls': calls or 0, + 'calls_with_usage': calls_with_usage or 0, + } + + async def _get_execution_summary( + self, + workspace_uuid: str, + *, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + statuses: list[str] | None = None, + mode: str = 'all', + ) -> dict: + AgentRun = persistence_agent_run.AgentRun + run_conditions = self._agent_run_conditions( + workspace_uuid, + bot_ids=bot_ids, + processor_ids=pipeline_ids, + start_time=start_time, + end_time=end_time, + statuses=self._raw_agent_statuses(statuses), + debug=True if mode == 'debug' else False if mode == 'real' else None, + ) + run_status_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(AgentRun.status, sqlalchemy.func.count(AgentRun.id)) + .where(sqlalchemy.and_(*run_conditions)) + .group_by(AgentRun.status) + ) + agent_counts = {row[0]: row[1] for row in run_status_result.all()} + agent_total = sum(agent_counts.values()) + + debug_count_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count(AgentRun.id)).where( + sqlalchemy.and_( + *run_conditions, + self._debug_run_condition(AgentRun), + ) + ) + ) + debug_count = debug_count_result.scalar() or 0 + + # Percentiles are computed in Python, so the sample must stay bounded: + # take the most recent completed runs of the window and label the count + # so the UI can state how many runs the numbers rest on. + timing_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at) + .where( + sqlalchemy.and_( + *run_conditions, + AgentRun.status == 'completed', + AgentRun.started_at.is_not(None), + AgentRun.finished_at.is_not(None), + ) + ) + .order_by(AgentRun.created_at.desc()) + .limit(self._detail_limit()) + ) + durations = sorted((row[1] - row[0]).total_seconds() * 1000 for row in timing_result.all() if row[0] and row[1]) + + pipeline_conditions = self._pipeline_execution_conditions( + workspace_uuid, + bot_ids=bot_ids, + pipeline_ids=pipeline_ids, + statuses=self._raw_pipeline_statuses(statuses), + start_time=start_time, + end_time=end_time, + debug=True if mode == 'debug' else False if mode == 'real' else None, + ) + Message = persistence_monitoring.MonitoringMessage + pipeline_timing = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(AgentRun.started_at, AgentRun.finished_at) + .select_from(Message) + .join( + AgentRun, + sqlalchemy.and_( + AgentRun.workspace_id == workspace_uuid, + AgentRun.run_id == Message.run_id, + ), + ) + .where( + *pipeline_conditions, + Message.status == 'success', + AgentRun.started_at.is_not(None), + AgentRun.finished_at.is_not(None), + ) + .order_by(Message.timestamp.desc()) + .limit(self._detail_limit()) + ) + durations.extend((row[1] - row[0]).total_seconds() * 1000 for row in pipeline_timing.all() if row[0] and row[1]) + durations.sort() + pipeline_status_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select( + persistence_monitoring.MonitoringMessage.status, + sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id), + ) + .where(sqlalchemy.and_(*pipeline_conditions)) + .group_by(persistence_monitoring.MonitoringMessage.status) + ) + pipeline_counts = {row[0]: row[1] for row in pipeline_status_result.all()} + pipeline_total = sum(pipeline_counts.values()) + pipeline_debug_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where( + *pipeline_conditions, self._debug_pipeline_condition(persistence_monitoring.MonitoringMessage) + ) + ) + debug_count += pipeline_debug_result.scalar() or 0 + + # Pending interactions are gated by the same bot/window scope as the runs + # so "waiting input" cannot drift from the rest of the card. + waiting_conditions = [ + persistence_agent_interaction.AgentInteraction.workspace_id == workspace_uuid, + persistence_agent_interaction.AgentInteraction.status == 'pending', + ] + if bot_ids: + waiting_conditions.append(persistence_agent_interaction.AgentInteraction.bot_id.in_(bot_ids)) + if pipeline_ids: + waiting_conditions.append( + persistence_agent_interaction.AgentInteraction.run_id.in_( + self._processor_runs(workspace_uuid, pipeline_ids) + ) + ) + if start_time: + waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at >= start_time) + if end_time: + waiting_conditions.append(persistence_agent_interaction.AgentInteraction.created_at <= end_time) + waiting_conditions.append( + persistence_agent_interaction.AgentInteraction.run_id.in_( + sqlalchemy.select(AgentRun.run_id).where(*run_conditions) + ) + ) + waiting_result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count(persistence_agent_interaction.AgentInteraction.id)).where( + sqlalchemy.and_(*waiting_conditions) + ) + ) + waiting = waiting_result.scalar() or 0 + + by_status: dict[str, int] = {} + for status, count in agent_counts.items(): + group = _status_group(status, 'agent') + by_status[group] = by_status.get(group, 0) + count + for status, count in pipeline_counts.items(): + group = _status_group(status, 'pipeline') + by_status[group] = by_status.get(group, 0) + count + + completed = by_status.get('completed', 0) + failed = by_status.get('failed', 0) + denominator = completed + failed + total = agent_total + pipeline_total + return { + 'total': total, + 'by_source': {'agent': agent_total, 'pipeline': pipeline_total}, + 'by_status': by_status, + 'completed': completed, + 'failed': failed, + 'running': by_status.get('running', 0), + 'queued': by_status.get('queued', 0), + 'cancelled': by_status.get('cancelled', 0), + 'ignored': by_status.get('ignored', 0), + 'waiting': waiting, + 'success_rate': round(completed / denominator * 100, 2) if denominator > 0 else None, + 'denominator': denominator, + 'p50_duration_ms': _percentile_ms(durations, 0.5), + 'p95_duration_ms': _percentile_ms(durations, 0.95), + 'duration_sample': len(durations), + 'debug': debug_count, + 'real': total - debug_count, + } + + @staticmethod + def _inflight_pipeline_condition(*, pending_only: bool = False): + """Historical pending records without a live Host lease are not tasks.""" + Message = persistence_monitoring.MonitoringMessage + live_lease = sqlalchemy.and_( + Message.execution_owner_id.is_not(None), + Message.execution_lease_expires_at > datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), + ) + # Active-only queries must expose the lease range to the composite index. + # The status OR otherwise reads historical payload pages to check role. + if pending_only: + return live_lease + return sqlalchemy.or_( + Message.status != 'pending', + live_lease, + ) + + async def get_executions( + self, + context: TenantContext, + *, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + agent_ids: list[str] | None = None, + statuses: list[str] | None = None, + source: str = 'all', + mode: str = 'all', + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 50, + offset: int = 0, + include_summary: bool = True, + for_inflight: bool = False, + ) -> dict: + """List executions from both object types (agent runs and pipeline queries).""" + workspace_uuid = require_workspace_uuid(context) + limit, offset = self.normalize_page_window(limit, offset) + if source not in {'all', 'agent', 'pipeline', 'event'}: + source = 'all' + if mode not in {'all', 'real', 'debug'}: + mode = 'all' + debug = None if mode == 'all' else mode == 'debug' + branches = [] + AgentRun = persistence_agent_run.AgentRun + Message = persistence_monitoring.MonitoringMessage + if source in {'all', 'agent'}: + conditions = self._agent_run_conditions( + workspace_uuid, + bot_ids=bot_ids, + agent_ids=agent_ids, + processor_ids=pipeline_ids, + statuses=self._raw_agent_statuses(statuses), + start_time=start_time, + end_time=end_time, + debug=debug, + ) + branches.append( + sqlalchemy.select( + sqlalchemy.literal('agent').label('source'), + AgentRun.run_id.label('id'), + AgentRun.created_at.label('time'), + ).where(*conditions) + ) + if source in {'all', 'pipeline'} and (not agent_ids or pipeline_ids): + pipeline_statuses = self._raw_pipeline_statuses(statuses) + conditions = self._pipeline_execution_conditions( + workspace_uuid, + bot_ids=bot_ids, + pipeline_ids=pipeline_ids, + statuses=pipeline_statuses, + start_time=start_time, + end_time=end_time, + debug=debug, + ) + if for_inflight: + conditions.append(self._inflight_pipeline_condition(pending_only=pipeline_statuses == ['pending'])) + branches.append( + sqlalchemy.select( + sqlalchemy.literal('pipeline').label('source'), + Message.id.label('id'), + Message.timestamp.label('time'), + ).where(*conditions) + ) + if source in {'all', 'event'} and mode != 'debug' and not pipeline_ids and not agent_ids and not for_inflight: + conditions = self._unhandled_event_conditions(workspace_uuid, bot_ids, start_time, end_time) + if statuses: + conditions.append(self._execution_json_text(EventLog.metadata_json, 'status').in_(statuses)) + branches.append( + sqlalchemy.select( + sqlalchemy.literal('event').label('source'), + EventLog.event_id.label('id'), + EventLog.created_at.label('time'), + ).where(*conditions) + ) + total = 0 + page = [] + if branches: + combined = sqlalchemy.union_all(*branches).subquery() + count = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(sqlalchemy.func.count()).select_from(combined) + ) + total = count.scalar() or 0 + refs = ( + await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(combined) + .order_by(combined.c.time.desc(), combined.c.source, combined.c.id) + .offset(offset) + .limit(limit) + ) + ).all() + by_key = {} + for kind, model, identity_column in [ + ('agent', AgentRun, AgentRun.run_id), + ('pipeline', Message, Message.id), + ('event', EventLog, EventLog.event_id), + ]: + ids = [r.id for r in refs if r.source == kind] + if not ids: + continue + workspace_column = model.workspace_uuid if kind == 'pipeline' else model.workspace_id + records = await self._execution_rows( + workspace_uuid, + self._execution_statement(model, summary_only=for_inflight).where( + workspace_column == workspace_uuid, identity_column.in_(ids) + ), + summary_only=for_inflight, + ) + names = await self._agent_names(workspace_uuid, records) if kind == 'agent' else {} + for record in records: + item = ( + self._serialize_agent_execution(record, names) + if kind == 'agent' + else self._serialize_pipeline_execution(record, summary_only=for_inflight) + if kind == 'pipeline' + else self._serialize_event_execution(record) + ) + by_key[(kind, item['id'])] = item + page = [by_key[(ref.source, ref.id)] for ref in refs if (ref.source, ref.id) in by_key] + await self.enrich_execution_rows(workspace_uuid, page, summary_only=for_inflight) + if not include_summary: + return {'items': page, 'total': total, 'has_more': total > offset + len(page)} + return { + 'items': page, + 'total': total, + 'limit': limit, + 'offset': offset, + 'has_more': total > offset + len(page), + 'summary': { + 'executions': await self._get_execution_summary( + workspace_uuid, + statuses=statuses, + mode=mode, + bot_ids=bot_ids, + pipeline_ids=pipeline_ids, + start_time=start_time, + end_time=end_time, + ), + 'tokens': await self._get_token_coverage( + workspace_uuid, + execution_statuses=statuses, + mode=mode, + bot_ids=bot_ids, + pipeline_ids=pipeline_ids, + start_time=start_time, + end_time=end_time, + ), + }, + } + + async def get_inflight_snapshot(self, context: TenantContext, previous: list[dict] | None = None) -> dict: + """SSE producers outlive the HTTP handler's tenant scope.""" + workspace = require_workspace_uuid(context) + tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) + if callable(tenant_scope): + async with tenant_scope(workspace): + return await self._get_inflight_snapshot(context, previous) + return await self._get_inflight_snapshot(context, previous) + + async def _get_inflight_snapshot(self, context: TenantContext, previous: list[dict] | None = None) -> dict: + """Bounded Workspace snapshot without dashboard aggregates or token payloads.""" + from ....utils.inflight import inflight_hub + + workspace = require_workspace_uuid(context) + active = await self.get_executions( + context, statuses=['running', 'queued'], limit=100, include_summary=False, for_inflight=True + ) + # Execution timestamps are stored as UTC without a timezone. asyncpg + # rejects aware datetimes for these columns, even though SQLite accepts them. + recent_start = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta( + seconds=30 + ) + recent = await self.get_executions( + context, + start_time=recent_start, + limit=100, + include_summary=False, + for_inflight=True, + ) + items = {(r['source'], r['id']): r for r in recent['items'] + active['items'] if r['source'] != 'event'} + # Keep the actual terminal state of older runs that just left the active set. + for kind, model, column, scope in [ + ( + 'agent', + persistence_agent_run.AgentRun, + persistence_agent_run.AgentRun.run_id, + persistence_agent_run.AgentRun.workspace_id, + ), + ( + 'pipeline', + persistence_monitoring.MonitoringMessage, + persistence_monitoring.MonitoringMessage.id, + persistence_monitoring.MonitoringMessage.workspace_uuid, + ), + ]: + missing = [r['id'] for r in (previous or [])[:100] if r['source'] == kind and (kind, r['id']) not in items] + if missing: + statement = self._execution_statement(model, summary_only=True).where( + scope == workspace, column.in_(missing) + ) + if kind == 'pipeline': + statement = statement.where(self._inflight_pipeline_condition()) + records = await self._execution_rows(workspace, statement, summary_only=True) + names = await self._agent_names(workspace, records) if kind == 'agent' else {} + for record in records: + row = ( + self._serialize_agent_execution(record, names) + if kind == 'agent' + else self._serialize_pipeline_execution(record, summary_only=True) + ) + items[(kind, row['id'])] = row + rows = list(items.values()) + snapshot = { + 'items': rows, + 'active_total': active['total'], + 'truncated': active['has_more'] or recent['has_more'], + } + if not rows: + inflight_hub.track(workspace, []) + return snapshot + Run = persistence_agent_run.AgentRun + Message = persistence_monitoring.MonitoringMessage + agent_ids = [r['id'] for r in rows if r['source'] == 'agent'] + pipeline_ids = [r['id'] for r in rows if r['source'] == 'pipeline'] + links = [] + if agent_ids: + links.append( + sqlalchemy.select(Run.run_id, sqlalchemy.literal(None).label('message_id')).where( + Run.workspace_id == workspace, Run.run_id.in_(agent_ids) + ) + ) + if pipeline_ids: + # Start from the selected message IDs. A cross-table OR after a + # LEFT JOIN makes SQLite scan the attachment-heavy message ledger + # whenever the snapshot contains even one Pipeline execution. + selected_messages = sqlalchemy.union_all( + *[sqlalchemy.select(sqlalchemy.literal(message_id).label('message_id')) for message_id in pipeline_ids] + ).subquery('selected_messages') + links.append( + sqlalchemy.select(Run.run_id, Message.id.label('message_id')) + .select_from(selected_messages) + .join(Message, Message.id == selected_messages.c.message_id) + .join(Run, sqlalchemy.and_(Run.workspace_id == workspace, Run.run_id == Message.run_id)) + .where(Message.workspace_uuid == workspace) + ) + linked = await self.ap.persistence_mgr.execute_async( + links[0] if len(links) == 1 else sqlalchemy.union_all(*links) + ) + identities = { + r.run_id: ('pipeline', r.message_id) if r.message_id in pipeline_ids else ('agent', r.run_id) + for r in linked.all() + } + inflight_hub.track(workspace, identities) + Event = persistence_agent_run.AgentRunEvent + latest = ( + sqlalchemy.select(Event.run_id, sqlalchemy.func.max(Event.sequence).label('seq')) + .where(Event.run_id.in_(identities)) + .group_by(Event.run_id) + .subquery() + ) + phases = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(Event.run_id, Event.type).join( + latest, sqlalchemy.and_(Event.run_id == latest.c.run_id, Event.sequence == latest.c.seq) + ) + ) + for run_id, event_type in phases.all(): + row = items.get(identities[run_id]) + if row is not None: + row['progress_event'] = event_type + return snapshot + async def get_token_statistics( self, context: TenantContext, @@ -902,6 +1834,8 @@ class MonitoringService: start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, bucket: str = 'hour', + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> dict: """Get detailed token usage statistics for production observability. @@ -920,10 +1854,15 @@ class MonitoringService: bucket = 'hour' conditions = [LLMCall.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(LLMCall.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(LLMCall.pipeline_id.in_(pipeline_ids)) + conditions.append(self._monitoring_processor_condition(LLMCall, workspace_uuid, pipeline_ids)) if start_time: conditions.append(LLMCall.timestamp >= start_time) if end_time: @@ -1104,16 +2043,40 @@ class MonitoringService: end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get messages with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) + if bot_ids and session_ids and not pipeline_ids and not execution_statuses and mode == 'all': + source = self._session_message_source(workspace_uuid, bot_ids) + query = sqlalchemy.select(source).where(source.c.session_id.in_(session_ids)) + if start_time: + query = query.where(source.c.timestamp >= start_time) + if end_time: + query = query.where(source.c.timestamp <= end_time) + return await self._session_projection_page( + workspace_uuid, + query.order_by(source.c.timestamp.desc(), source.c.id.desc()), + limit, + offset, + ) conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringMessage, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids + ) + ) if session_ids: conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids)) if start_time: @@ -1159,16 +2122,27 @@ class MonitoringService: end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get LLM calls with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) if end_time: @@ -1214,16 +2188,27 @@ class MonitoringService: end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get tool calls with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringToolCall, workspace_uuid, execution_statuses, mode + ) + ) 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)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringToolCall, workspace_uuid, pipeline_ids + ) + ) if session_ids: conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids)) if start_time: @@ -1267,12 +2252,19 @@ class MonitoringService: knowledge_base_id: str | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get embedding calls with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringEmbeddingCall, workspace_uuid, execution_statuses, mode + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) if end_time: @@ -1310,6 +2302,89 @@ class MonitoringService: total, ) + @_workspace_transaction + async def reset_session_context(self, context: TenantContext, bot_id: str, session_id: str) -> dict: + """Reset model context without deleting monitoring history or other sessions.""" + from ....agent.runner.context_reset import reset_runner_context + + if not isinstance(bot_id, str) or not bot_id or not isinstance(session_id, str) or not session_id: + raise ValueError('bot_id and session_id are required') + workspace = require_workspace_uuid(context) + execute = self.ap.persistence_mgr.execute_async + source = self._session_message_source(workspace, [bot_id]) + exists = ( + await execute( + sqlalchemy.select(source.c.session_id) + .where( + source.c.session_id == session_id, + ) + .limit(1) + ) + ).first() + if exists is None: + session = persistence_monitoring.MonitoringSession + exists = ( + await execute( + sqlalchemy.select(session.session_id) + .where( + session.workspace_uuid == workspace, + session.bot_id == bot_id, + session.session_id == session_id, + ) + .limit(1) + ) + ).first() + if exists is None: + raise LookupError('Session not found') + # Legacy sessions use uppercase PERSON/GROUP; envelope IDs use lowercase. + conversation = re.sub(r'^(PERSON|GROUP)_', lambda match: match[1].lower() + '_', session_id) + run = persistence_agent_run.AgentRun + busy = ( + await execute( + sqlalchemy.select(run.run_id) + .where( + run.workspace_id == workspace, + run.bot_id == bot_id, + run.conversation_id == conversation, + run.status.in_(['created', 'queued', 'claimed', 'running']), + ) + .limit(1) + ) + ).first() + message = persistence_monitoring.MonitoringMessage + pending = ( + await execute( + sqlalchemy.select(message.id) + .where( + message.workspace_uuid == workspace, + message.bot_id == bot_id, + message.session_id.in_([session_id, conversation]), + message.status == 'pending', + ) + .limit(1) + ) + ).first() + interaction = persistence_agent_interaction.AgentInteraction + waiting = ( + await execute( + sqlalchemy.select(interaction.id) + .where( + interaction.workspace_id == workspace, + interaction.bot_id == bot_id, + interaction.conversation_id == conversation, + interaction.status.in_(['pending', 'submitted']), + sqlalchemy.or_( + interaction.expires_at.is_(None), interaction.expires_at > datetime.datetime.utcnow() + ), + ) + .limit(1) + ) + ).first() + if busy or pending or waiting: + raise RuntimeError('Session is processing a task; wait until it finishes') + await reset_runner_context(self.ap.persistence_mgr, workspace, bot_id, conversation) + return {'reset': True, 'session_id': session_id} + async def get_sessions( self, context: TenantContext, @@ -1321,16 +2396,38 @@ class MonitoringService: is_active: bool | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get sessions with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) + if bot_ids and not pipeline_ids and not execution_statuses and mode == 'all': + return await self.get_bot_conversation_sessions( + workspace_uuid, + bot_ids, + start_time, + end_time, + user_query, + is_active, + limit, + offset, + ) conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time) if end_time: @@ -1385,16 +2482,27 @@ class MonitoringService: end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get errors with filters""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringError, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) if end_time: @@ -1860,7 +2968,11 @@ class MonitoringService: if bot_ids: conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringMessage, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) if end_time: @@ -1916,7 +3028,11 @@ class MonitoringService: if bot_ids: conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringLLMCall, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) if end_time: @@ -2023,7 +3139,11 @@ class MonitoringService: if bot_ids: conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringError, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) if end_time: @@ -2074,7 +3194,11 @@ class MonitoringService: if bot_ids: conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time) if end_time: @@ -2227,6 +3351,8 @@ class MonitoringService: pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> dict: """Get feedback statistics. @@ -2236,10 +3362,19 @@ class MonitoringService: workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time) if end_time: @@ -2320,16 +3455,27 @@ class MonitoringService: end_time: datetime.datetime | None = None, limit: int = 100, offset: int = 0, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> tuple[list[dict], int]: """Get feedback list with filters.""" limit, offset = self.normalize_page_window(limit, offset) workspace_uuid = require_workspace_uuid(context) conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid] + conditions.append( + self._monitoring_execution_condition( + persistence_monitoring.MonitoringFeedback, workspace_uuid, execution_statuses, mode + ) + ) if bot_ids: conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids + ) + ) if feedback_type is not None: conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type) if start_time: @@ -2382,7 +3528,11 @@ class MonitoringService: if bot_ids: conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids)) + conditions.append( + self._monitoring_processor_condition( + persistence_monitoring.MonitoringFeedback, workspace_uuid, pipeline_ids + ) + ) if start_time: conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time) if end_time: diff --git a/src/langbot/pkg/api/http/service/monitoring_execution_details.py b/src/langbot/pkg/api/http/service/monitoring_execution_details.py new file mode 100644 index 000000000..9c65f3f2c --- /dev/null +++ b/src/langbot/pkg/api/http/service/monitoring_execution_details.py @@ -0,0 +1,895 @@ +"""Tenant-scoped execution inputs, outputs, related runs and conversation history.""" + +from __future__ import annotations + +import datetime +import json +from types import SimpleNamespace +from langbot_plugin.api.entities.builtin.platform.events import event_summary + +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import AsyncSession + +from ....entity.persistence.agent_run import AgentRun, AgentRunEvent +from ....entity.persistence.event_log import EventLog +from ....entity.persistence.transcript import Transcript +from ....entity.persistence import monitoring as models +from .tenant import require_workspace_uuid + + +def epoch(value): + if value is None: + return None + return int(value.replace(tzinfo=datetime.timezone.utc).timestamp() * 1000) + + +def parsed(value, fallback=None): + try: + return json.loads(value) if value else fallback + except (TypeError, ValueError): + return fallback + + +def event_content(content, event_data): + """Keep event-specific properties alongside the normalized message input.""" + if not isinstance(event_data, dict) or not event_data: + return content + # Keep the original event intact; text is the existing normalized input field. + return {'text': event_summary(event_data), 'event': event_data, 'input': content} + + +def event_preview(payload, fallback='', event_type=''): + """Never use serialized event metadata as a human-readable list preview.""" + if event_type.startswith('wecomcs.'): + return '' + if isinstance(payload, dict): + if payload.get('type'): + if str(payload['type']).startswith('wecomcs.'): + return '' + return event_summary(payload)[:1000] + if isinstance(payload.get('text'), str): + return payload['text'][:1000] + if fallback and not isinstance(parsed(fallback), (dict, list)): + return fallback[:1000] + return '' + + +def event_item(row): + metadata = parsed(row.metadata_json, {}) + return { + 'id': row.event_id, + 'event_type': row.event_type, + 'source': row.source, + 'timestamp_ms': epoch(row.event_time or row.created_at), + 'actor_id': row.actor_id, + 'actor_name': row.actor_name, + 'conversation_id': row.conversation_id, + 'thread_id': row.thread_id, + 'content': event_content(parsed(row.input_json, None) or row.input_summary, metadata.get('input_event')), + 'metadata': metadata, + } + + +def message_item(row): + return { + 'id': row.id, + 'role': row.role or 'user', + 'timestamp_ms': epoch(row.timestamp), + 'content': parsed(row.message_content, None) or row.message_content, + 'actor_id': row.user_id, + 'actor_name': row.user_name, + 'status': row.status, + 'event_id': row.event_id, + 'run_id': row.run_id, + 'origin': 'delivery' if row.role == 'assistant' else 'input', + 'delivery': parsed(row.variables, {}) if row.role == 'assistant' else None, + } + + +def transcript_item(row): + return { + 'id': row.transcript_id, + 'role': row.role, + 'timestamp_ms': epoch(row.created_at), + 'content': parsed(row.content_json, None) or row.content, + 'attachments': parsed(row.attachment_refs_json, []), + 'event_id': row.event_id, + 'run_id': row.run_id, + 'origin': 'generated' if row.role == 'assistant' else 'input', + } + + +class ExecutionDetailsMixin: + def _session_message_source(self, workspace, bot_ids, *, summary_only=False): + """Combine delivered messages with ingress missing from the legacy ledger.""" + message = models.MonitoringMessage + conversation = sa.func.coalesce( + EventLog.conversation_id, + sa.select(AgentRun.conversation_id) + .where( + AgentRun.workspace_id == workspace, + AgentRun.bot_id == EventLog.bot_id, + AgentRun.event_id == EventLog.event_id, + ) + .order_by(AgentRun.id) + .limit(1) + .scalar_subquery(), + sa.case( + ( + self._execution_json_text(EventLog.input_json, 'chat_type') == 'group', + sa.literal('group_') + self._execution_json_text(EventLog.input_json, 'chat_id'), + ), + ( + self._execution_json_text(EventLog.input_json, 'chat_type') == 'private', + sa.literal('person_') + self._execution_json_text(EventLog.input_json, ('sender', 'id')), + ), + ), + ) + values = { + 'id': EventLog.event_id, + 'workspace_uuid': EventLog.workspace_id, + 'timestamp': sa.func.coalesce(EventLog.event_time, EventLog.created_at), + 'bot_id': EventLog.bot_id, + 'bot_name': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'bot_name'), ''), + 'pipeline_id': sa.func.coalesce( + self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_uuid')), '' + ), + 'pipeline_name': sa.func.coalesce( + self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), '' + ), + 'message_content': sa.func.coalesce(EventLog.input_json, EventLog.input_summary, ''), + 'session_id': conversation, + 'status': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'status'), 'success'), + 'level': sa.literal('info'), + 'platform': self._execution_json_text(EventLog.metadata_json, 'platform'), + 'user_id': EventLog.actor_id, + 'user_name': EventLog.actor_name, + 'role': sa.literal('user'), + 'event_id': EventLog.event_id, + 'run_id': EventLog.run_id, + } + columns = [ + column + for column in message.__table__.columns + if not summary_only or column.name not in {'message_content', 'variables'} + ] + stored = sa.select(*columns).where(message.workspace_uuid == workspace, message.bot_id.in_(bot_ids)) + incoming = sa.select( + *[values.get(column.name, sa.literal(None)).label(column.name) for column in columns] + ).where( + EventLog.workspace_id == workspace, + EventLog.bot_id.in_(bot_ids), + EventLog.source == 'platform', + EventLog.event_type == 'message.received', + conversation.is_not(None), + conversation != '', + ~sa.exists( + sa.select(message.id).where( + message.workspace_uuid == workspace, + message.bot_id == EventLog.bot_id, + sa.func.coalesce(message.role, 'user') != 'assistant', + sa.or_(message.event_id == EventLog.event_id, message.id == EventLog.event_id), + ) + ), + ) + return sa.union_all(stored, incoming).subquery() + + async def _session_projection_page(self, workspace, statement, limit, offset): + count = ( + await self._execution_query(workspace, sa.select(sa.func.count()).select_from(statement.subquery())) + ).scalar_one() + rows = (await self._execution_query(workspace, statement.limit(limit).offset(offset))).mappings().all() + return [ + {key: value.isoformat() if isinstance(value, datetime.datetime) else value for key, value in row.items()} + for row in rows + ], count + + async def get_bot_conversation_sessions( + self, workspace, bot_ids, start_time, end_time, user_query, is_active, limit, offset + ): + """Read session summaries across processor types without mutating history.""" + # Window functions sort and retain their input rows. Carrying historical + # inline media through that sort can consume gigabytes for a summary. + messages = self._session_message_source(workspace, bot_ids, summary_only=True) + key = [messages.c.workspace_uuid, messages.c.bot_id, messages.c.session_id] + ranked = ( + sa.select( + *messages.c, + sa.func.row_number() + .over( + partition_by=key, + order_by=[ + sa.case((messages.c.role == 'user', 0), else_=1), + messages.c.timestamp.desc(), + messages.c.id.desc(), + ], + ) + .label('position'), + sa.func.min(messages.c.timestamp).over(partition_by=key).label('first_seen'), + sa.func.max(messages.c.timestamp).over(partition_by=key).label('last_seen'), + sa.func.count().over(partition_by=key).label('count'), + ) + .where(messages.c.session_id != '') + .subquery() + ) + session = models.MonitoringSession + fields = list(session.__table__.columns) + projected = { + 'start_time': ranked.c.first_seen, + 'last_activity': ranked.c.last_seen, + 'message_count': ranked.c['count'], + 'is_active': sa.literal(True), + } + derived = sa.select( + *[ + (projected[field.name] if field.name in projected else ranked.c[field.name]).label(field.name) + for field in fields + ] + ).where(ranked.c.position == 1) + existing = sa.select(*fields).where(session.workspace_uuid == workspace, session.bot_id.in_(bot_ids)) + combined = sa.union_all(existing, derived).subquery() + group = [combined.c.workspace_uuid, combined.c.bot_id, combined.c.session_id] + merged = sa.select( + *[ + combined.c[field.name] + for field in fields + if field.name not in {'message_count', 'start_time', 'last_activity'} + ], + sa.func.max(combined.c.message_count).over(partition_by=group).label('message_count'), + sa.func.min(combined.c.start_time).over(partition_by=group).label('start_time'), + sa.func.max(combined.c.last_activity).over(partition_by=group).label('last_activity'), + sa.func.row_number().over(partition_by=group, order_by=combined.c.last_activity.desc()).label('position'), + ).subquery() + query = sa.select(*[merged.c[field.name] for field in fields]).where(merged.c.position == 1) + if start_time: + query = query.where(merged.c.last_activity >= start_time) + if end_time: + query = query.where(merged.c.last_activity <= end_time) + if user_query and user_query.strip(): + pattern = f'%{user_query.strip()}%' + query = query.where(sa.or_(merged.c.user_id.ilike(pattern), merged.c.user_name.ilike(pattern))) + if is_active is not None: + query = query.where(merged.c.is_active == is_active) + return await self._session_projection_page( + workspace, + query.order_by(merged.c.last_activity.desc(), merged.c.session_id), + limit, + offset, + ) + + def _execution_json_text(self, column, key): + # SQLite JSON functions accept TEXT directly; PostgreSQL operators + # require an explicit JSON cast for the existing text-backed journals. + if self.ap.persistence_mgr.get_db_engine().dialect.name == 'sqlite': + expression = sa.type_coerce(column, sa.JSON) + else: + expression = sa.cast(column, sa.JSON) + return expression[key].as_string() + + async def link_execution_message(self, context, message_id, run_id, event_id): + workspace = self._require_write_context(context) + statement = ( + sa.update(models.MonitoringMessage) + .where( + models.MonitoringMessage.workspace_uuid == workspace, + models.MonitoringMessage.id == message_id, + ) + .values(run_id=run_id, event_id=sa.func.coalesce(models.MonitoringMessage.event_id, event_id)) + ) + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if callable(tenant_uow): + async with tenant_uow(workspace) as uow: + await uow.session.execute(statement) + else: + await self.ap.persistence_mgr.execute_async(statement) + + async def record_ingress_event( + self, + context, + *, + event_id, + event_type, + bot_id, + bot_name, + platform, + payload, + actor_id=None, + actor_name=None, + conversation_id=None, + status='running', + routes=None, + ): + """Persist entry facts even when routing never creates a processor run.""" + from ....agent.runner.event_log_store import EventLogStore + + workspace = self._require_write_context(context) + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + # Reuse the message sanitizer (including inline-media handling) instead + # of storing an unbounded adapter object in the event journal. + content = parsed(self._sanitize_message_content(json.dumps(payload, ensure_ascii=False, default=str)), {}) + summary = event_preview(content, event_type=event_type) + await store.append_event( + event_id=event_id, + event_type=event_type, + source='platform', + workspace_id=workspace, + bot_id=bot_id, + actor_id=actor_id, + actor_name=actor_name, + conversation_id=conversation_id, + actor_type='user' if actor_id else 'system', + input_summary=summary, + input_json=content, + metadata={'status': status, 'bot_name': bot_name, 'platform': platform}, + ) + if routes is not None: + safe_routes = [] + for route in routes: + if isinstance(route, BaseException): + safe_routes.append({'status': 'failed', 'reason': str(route) or type(route).__name__}) + elif isinstance(route, dict): + safe_routes.append( + { + key: route.get(key) + for key in ( + 'status', + 'reason', + 'target_type', + 'target_uuid', + 'run_id', + 'failure_code', + ) + } + ) + metadata = {'status': status, 'bot_name': bot_name, 'platform': platform, 'routes': safe_routes} + async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session: + await session.execute( + sa.update(EventLog) + .where( + EventLog.workspace_id == workspace, + EventLog.event_id == event_id, + ) + .values(metadata_json=json.dumps(metadata, ensure_ascii=False)) + ) + await session.commit() + + async def _execution_query(self, workspace, statement): + tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None) + if callable(tenant_uow): + async with tenant_uow(workspace) as uow: + return await uow.session.execute(statement) + async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session: + return await session.execute(statement) + + async def _execution_rows(self, workspace, statement, *, summary_only=False): + result = await self._execution_query(workspace, statement) + if not summary_only: + return list(result.scalars().all()) + records = [] + for row in result.mappings(): + fields = dict(row) + metadata = { + key.removeprefix('display_metadata_'): fields.pop(key) + for key in row + if key.startswith('display_metadata_') + } + if metadata: + fields['metadata_json'] = json.dumps(metadata, ensure_ascii=False) + records.append(SimpleNamespace(**fields)) + return records + + def _execution_statement(self, model, *, summary_only=False): + """Keep periodic snapshots independent of message and attachment sizes.""" + if not summary_only: + return sa.select(model) + columns = [ + column + for column in model.__table__.columns + if column.name not in {'message_content', 'variables', 'input_json', 'authorization_json', 'metadata_json'} + ] + if model is models.MonitoringMessage: + return sa.select(*columns) + keys = ( + ('processor_id', 'processor_type', 'event_type', 'agent_name', 'target_name', 'bot_name') + if model is AgentRun + else ('platform', 'bot_name') + ) + # Metadata also contains complete inputs. Project only the small display + # fields in SQL, rather than deserializing an attachment-bearing object. + for key in keys: + columns.append(self._execution_json_text(model.metadata_json, key).label(f'display_metadata_{key}')) + return sa.select(*columns) + + async def _execution_page(self, workspace, statement, convert, offset, limit): + rows = await self._execution_rows(workspace, statement.offset(offset).limit(limit + 1)) + return { + 'items': [convert(row) for row in rows[:limit]], + 'has_more': len(rows) > limit, + 'next_offset': offset + min(len(rows), limit), + } + + async def _conversation_message_page(self, workspace, statement, offset, limit): + """Select a page from the covering index before loading its payloads.""" + message = models.MonitoringMessage + identities = list( + ( + await self._execution_query( + workspace, statement.with_only_columns(message.id).offset(offset).limit(limit + 1) + ) + ).scalars() + ) + selected = identities[:limit] + records = {} + if selected: + # Equality joins keep SQLite on the primary key even for multiple IDs. + page = sa.union_all(*[sa.select(sa.literal(identity).label('id')) for identity in selected]).subquery() + rows = await self._execution_rows( + workspace, + sa.select(message) + .select_from(page) + .join(message, message.id == page.c.id) + .where(message.workspace_uuid == workspace), + ) + records = {row.id: row for row in rows} + return { + 'items': [message_item(records[identity]) for identity in selected if identity in records], + 'has_more': len(identities) > limit, + 'next_offset': offset + len(selected), + } + + def _unhandled_event_conditions(self, workspace, bot_ids=None, start_time=None, end_time=None): + # Absorbed steering events belong to an existing run, too. + conditions = [ + EventLog.workspace_id == workspace, + # The primary route is first; plugin subscriptions follow it. + # Delivered pipeline ingress is not an unhandled execution, including + # older queued/aggregated messages whose trace identity was lost. + ~sa.and_( + sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), '') + == 'pipeline', + sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'status')), '') + == 'delivered', + ), + ~sa.exists( + sa.select(AgentRun.id).where( + AgentRun.workspace_id == workspace, + sa.or_( + AgentRun.event_id == EventLog.event_id, + AgentRun.run_id == EventLog.run_id, + self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id') == EventLog.event_id, + ), + ) + ), + ~sa.exists( + sa.select(models.MonitoringMessage.id).where( + models.MonitoringMessage.workspace_uuid == workspace, + models.MonitoringMessage.event_id == EventLog.event_id, + ) + ), + ] + if bot_ids: + conditions.append(EventLog.bot_id.in_(bot_ids)) + if start_time: + conditions.append(EventLog.created_at >= start_time) + if end_time: + conditions.append(EventLog.created_at <= end_time) + return conditions + + def _serialize_event_execution(self, row): + metadata = parsed(row.metadata_json, {}) + status = metadata.get('status', 'ignored') + return { + 'source': 'event', + 'id': row.event_id, + 'event_id': row.event_id, + 'status': status, + 'status_group': status, + 'title': row.event_type, + 'input_preview': event_preview(parsed(row.input_json), row.input_summary, row.event_type), + 'target_kind': 'event', + 'target_id': None, + 'target_name': None, + 'bot_id': row.bot_id, + 'bot_name': metadata.get('bot_name'), + 'platform': metadata.get('platform') or row.source, + 'user_id': row.actor_id, + 'user_name': row.actor_name, + 'conversation_id': row.conversation_id, + 'session_id': row.conversation_id, + 'created_at_ms': epoch(row.created_at), + 'started_at_ms': None, + 'finished_at_ms': None, + 'duration_ms': None, + 'usage': None, + 'cost': None, + 'queue_name': None, + 'pipeline_id': None, + 'pipeline_name': None, + 'runner_id': None, + 'debug': row.source == 'debug', + 'has_error': status == 'failed', + 'status_reason': metadata.get('reason') + or '; '.join( + route.get('reason') or route.get('failure_code') or 'failure_reason_not_recorded' + for route in metadata.get('routes', []) + if isinstance(route, dict) and route.get('status') == 'failed' + ) + or None, + } + + async def enrich_execution_rows(self, workspace, items, *, summary_only=False): + pipeline_items = {item['id']: item for item in items if item.get('source') == 'pipeline'} + if pipeline_items: + # Pipeline rows own the UI identity; their linked Runner ledger owns + # timing and usage. Read them in one batch for both list and detail. + linked = await self._execution_query( + workspace, + sa.select( + models.MonitoringMessage.id, + AgentRun.started_at, + AgentRun.finished_at, + AgentRun.usage_json, + AgentRun.cost_json, + ) + .select_from(models.MonitoringMessage) + .join( + AgentRun, + sa.and_( + AgentRun.workspace_id == workspace, + AgentRun.run_id == models.MonitoringMessage.run_id, + ), + ) + .where( + models.MonitoringMessage.workspace_uuid == workspace, + models.MonitoringMessage.id.in_(pipeline_items), + ), + ) + for run in linked.all(): + pipeline_items[run.id].update( + started_at_ms=epoch(run.started_at), + finished_at_ms=epoch(run.finished_at), + duration_ms=int(round((run.finished_at - run.started_at).total_seconds() * 1000)) + if run.started_at and run.finished_at + else None, + usage=self._deserialize_json(run.usage_json) or None, + cost=self._deserialize_json(run.cost_json) or None, + ) + + calls = models.MonitoringLLMCall + usage_rows = await self._execution_query( + workspace, + sa.select( + calls.message_id, + sa.func.sum(calls.input_tokens), + sa.func.sum(calls.output_tokens), + sa.func.sum(calls.total_tokens), + ) + .where( + calls.workspace_uuid == workspace, + calls.message_id.in_(pipeline_items), + ) + .group_by(calls.message_id), + ) + for message_id, input_tokens, output_tokens, total_tokens in usage_rows.all(): + if not pipeline_items[message_id].get('usage'): + pipeline_items[message_id]['usage'] = { + 'input_tokens': int(input_tokens or 0), + 'output_tokens': int(output_tokens or 0), + 'total_tokens': int(total_tokens or 0), + } + ids = {item['event_id'] for item in items if item.get('event_id')} + if not ids: + return + events = await self._execution_rows( + workspace, + self._execution_statement(EventLog, summary_only=summary_only).where( + EventLog.workspace_id == workspace, + EventLog.event_id.in_(ids), + ), + summary_only=summary_only, + ) + by_id = {row.event_id: row for row in events} + for item in items: + event = by_id.get(item.get('event_id')) + if event: + meta = parsed(event.metadata_json, {}) + item.update( + event_type=event.event_type, + input_preview=event_preview( + None if summary_only else parsed(event.input_json), event.input_summary, event.event_type + ), + user_id=event.actor_id, + user_name=event.actor_name, + platform=meta.get('platform') or event.source, + bot_name=item.get('bot_name') or meta.get('bot_name'), + ) + + async def get_execution_detail(self, context, source, execution_id, *, section=None, offset=0, limit=100): + workspace = require_workspace_uuid(context) + limit, offset = self.normalize_page_window(limit, offset) + limit = min(limit, 100) + sections = { + 'inputs', + 'outputs', + 'deliveries', + 'conversation', + 'related', + 'events', + 'llm_calls', + 'tool_calls', + 'errors', + } + if section is not None and section not in sections | {'overview'}: + raise ValueError('Unknown execution section') + requested = ( + sections + if section is None + else ({'inputs', 'outputs', 'deliveries', 'errors'} if section == 'overview' else {section}) + ) + + run = message = event = None + if source in {'agent', 'auto'}: + rows = await self._execution_rows( + workspace, + sa.select(AgentRun).where( + AgentRun.workspace_id == workspace, + AgentRun.run_id == execution_id, + ), + ) + run = rows[0] if rows else None + if run is None and source in {'pipeline', 'auto'}: + rows = await self._execution_rows( + workspace, + sa.select(models.MonitoringMessage).where( + models.MonitoringMessage.workspace_uuid == workspace, + models.MonitoringMessage.id == execution_id, + ), + ) + message = rows[0] if rows else None + if message and message.parent_message_id: + return await self.get_execution_detail( + context, 'auto', message.parent_message_id, section=section, offset=offset, limit=limit + ) + if run is None and message is None and source in {'event', 'auto'}: + rows = await self._execution_rows( + workspace, + sa.select(EventLog).where( + EventLog.workspace_id == workspace, + EventLog.event_id == execution_id, + ), + ) + event = rows[0] if rows else None + if run is None and message is None and event is None: + raise ValueError('Execution not found') + + if message and message.run_id: + linked = await self._execution_rows( + workspace, + sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == message.run_id), + ) + if linked: + linked_run = linked[0] + else: + linked_run = None + else: + linked_run = None + + if run: + names = await self._agent_names(workspace, [run]) + row = self._serialize_agent_execution(run, names) + event_id, bot, conversation, thread = run.event_id, run.bot_id, run.conversation_id, run.thread_id + run_id = run.run_id + elif message: + row = self._serialize_pipeline_execution(message) + event_id, bot, conversation, thread = message.event_id, message.bot_id, message.session_id, None + run_id = message.run_id + if linked_run: + row['duration_ms'] = ( + int((linked_run.finished_at - linked_run.started_at).total_seconds() * 1000) + if linked_run.started_at and linked_run.finished_at + else None + ) + row['usage'] = parsed(linked_run.usage_json) + row['status_reason'] = linked_run.status_reason + else: + row = self._serialize_event_execution(event) + event_id, bot, conversation, thread = event.event_id, event.bot_id, event.conversation_id, event.thread_id + run_id = event.run_id + await self.enrich_execution_rows(workspace, [row]) + + ledger = run or linked_run + ingress_id = parsed(ledger.metadata_json, {}).get('ingress_event_id') if ledger else None + event_conditions = [ + EventLog.workspace_id == workspace, + sa.or_( + EventLog.event_id == ingress_id if ingress_id else sa.false(), + EventLog.event_id == ledger.event_id if ledger else sa.false(), + EventLog.event_id == event_id if event_id else sa.false(), + EventLog.run_id == run_id if run_id else sa.false(), + ), + ] + event_ids = sa.select(EventLog.event_id).where(*event_conditions) + # All joins use explicit identities. Session/time matching is reserved + # for the visibly separate conversation history section. + message_scope = [models.MonitoringMessage.workspace_uuid == workspace] + related_message = sa.or_( + models.MonitoringMessage.id == message.id if message else sa.false(), + models.MonitoringMessage.parent_message_id == (message.id if message else run_id) + if message or run_id + else sa.false(), + models.MonitoringMessage.run_id == run_id if run_id else sa.false(), + ) + transcript_scope = [Transcript.workspace_id == workspace, Transcript.bot_id == bot] + transcripts = sa.select(Transcript).where( + *transcript_scope, + sa.or_( + Transcript.run_id == run_id if run_id else sa.false(), + sa.and_(Transcript.role == 'user', Transcript.event_id.in_(event_ids)), + ), + ) + call_ids = sa.select(models.MonitoringMessage.id).where(*message_scope, related_message) + statements = { + 'inputs': (sa.select(EventLog).where(*event_conditions).order_by(EventLog.id.asc()), event_item), + 'outputs': (transcripts.where(Transcript.role != 'user').order_by(Transcript.seq.asc()), transcript_item), + 'deliveries': ( + sa.select(models.MonitoringMessage) + .where(*message_scope, related_message, models.MonitoringMessage.role == 'assistant') + .order_by(models.MonitoringMessage.timestamp.asc(), models.MonitoringMessage.id.asc()), + message_item, + ), + 'events': ( + sa.select(AgentRunEvent) + .where(AgentRunEvent.run_id == run_id if run_id else sa.false()) + .order_by(AgentRunEvent.sequence.asc()), + lambda r: { + 'id': r.id, + 'sequence': r.sequence, + 'type': r.type, + 'data': parsed(r.data_json, {}), + 'timestamp_ms': epoch(r.created_at), + }, + ), + } + for name, model in [ + ('llm_calls', models.MonitoringLLMCall), + ('tool_calls', models.MonitoringToolCall), + ('errors', models.MonitoringError), + ]: + statements[name] = ( + sa.select(model) + .where( + model.workspace_uuid == workspace, + sa.or_( + model.message_id.in_(call_ids), + model.message_id == run_id if run_id else sa.false(), + ), + ) + .order_by(model.timestamp.asc(), model.id.asc()), + lambda r, model=model: self.ap.persistence_mgr.serialize_model(model, r), + ) + if message: + statements['conversation'] = ( + sa.select(models.MonitoringMessage) + .where( + *message_scope, + models.MonitoringMessage.bot_id == bot, + models.MonitoringMessage.session_id == conversation, + ) + .order_by(models.MonitoringMessage.timestamp.desc(), models.MonitoringMessage.id.desc()), + message_item, + ) + else: + statements['conversation'] = ( + sa.select(Transcript) + .where( + *transcript_scope, + Transcript.conversation_id == conversation if conversation else sa.false(), + Transcript.thread_id == thread, + ) + .order_by(Transcript.seq.desc()), + transcript_item, + ) + + pages = {} + for name, (query, convert) in statements.items(): + if name in requested: + pages[name] = ( + await self._conversation_message_page(workspace, query, offset, limit) + if name == 'conversation' and message + else await self._execution_page(workspace, query, convert, offset, limit) + ) + if run: + meta = parsed(run.metadata_json, {}) + for item in pages.get('inputs', {}).get('items', []): + # Older event logs omitted data, but the root run may retain it. + # Do not apply the root snapshot to later steering events. + if item['id'] == run.event_id and not item.get('metadata', {}).get('input_event'): + item['content'] = event_content(item['content'], meta.get('input_event')) + if message and 'inputs' in requested: + # The original rich message is authoritative even when the event + # log is absent or only retained a compact event summary. + if offset == 0: + pages['inputs']['items'].insert(0, message_item(message)) + if run and not pages.get('inputs', {}).get('items') and offset == 0 and 'inputs' in requested: + meta = parsed(run.metadata_json, {}) + content = event_content(meta.get('input'), meta.get('input_event')) + if content: + pages['inputs']['items'] = [ + {'id': run.event_id or run.run_id, 'content': content, 'timestamp_ms': epoch(run.created_at)} + ] + + if 'related' in requested: + # Union of ledger executions and legacy pipeline roots. SQL paging + # avoids silently dropping siblings on highly fanned-out events. + related_runs = sa.select( + sa.literal('agent').label('source'), AgentRun.run_id.label('id'), AgentRun.created_at.label('time') + ).where( + AgentRun.workspace_id == workspace, + sa.or_( + AgentRun.event_id.in_(event_ids), + self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id').in_(event_ids), + ), + AgentRun.run_id != (run_id or ''), + ~sa.exists().where( + models.MonitoringMessage.workspace_uuid == workspace, + models.MonitoringMessage.run_id == AgentRun.run_id, + models.MonitoringMessage.role != 'assistant', + ), + ) + related_messages = sa.select( + sa.literal('pipeline'), models.MonitoringMessage.id, models.MonitoringMessage.timestamp + ).where( + *message_scope, + models.MonitoringMessage.event_id.in_(event_ids), + models.MonitoringMessage.role != 'assistant', + models.MonitoringMessage.id != (message.id if message else ''), + ) + union = sa.union_all(related_runs, related_messages).subquery() + refs = ( + await self._execution_query( + workspace, + sa.select(union).order_by(union.c.time, union.c.source, union.c.id).offset(offset).limit(limit + 1), + ) + ).all() + related = [] + for ref in refs[:limit]: + if ref.source == 'agent': + records = await self._execution_rows( + workspace, + sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == ref.id), + ) + related.append( + self._serialize_agent_execution(records[0], await self._agent_names(workspace, records)) + ) + else: + records = await self._execution_rows( + workspace, + sa.select(models.MonitoringMessage).where( + *message_scope, models.MonitoringMessage.id == ref.id + ), + ) + related.append(self._serialize_pipeline_execution(records[0])) + pages['related'] = { + 'items': related, + 'has_more': len(refs) > limit, + 'next_offset': offset + min(len(refs), limit), + } + + result = { + 'source': row['source'], + 'row': row, + 'pages': pages, + 'legacy_context': bool(message and not message.event_id), + } + if run: + # Do not expose runtime authorization, leases or claim tokens. + result['run'] = {'run_id': run.run_id, 'status': run.status, 'status_reason': run.status_reason} + if message: + result['message'] = self.ap.persistence_mgr.serialize_model(models.MonitoringMessage, message) + # Backwards-compatible fields for existing API consumers. + for key in ('events', 'llm_calls', 'tool_calls', 'errors'): + if key in pages: + result[key] = pages[key]['items'] + result['has_more'] = pages.get('events', {}).get('has_more', False) + result['next_cursor'] = pages.get('events', {}).get('next_offset') + return result diff --git a/src/langbot/pkg/api/http/service/monitoring_traffic.py b/src/langbot/pkg/api/http/service/monitoring_traffic.py index 702d34e01..c8469c488 100644 --- a/src/langbot/pkg/api/http/service/monitoring_traffic.py +++ b/src/langbot/pkg/api/http/service/monitoring_traffic.py @@ -24,9 +24,14 @@ async def get_traffic_series( pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + execution_statuses: list[str] | None = None, + mode: str = 'all', ) -> dict: """Count all matching records in UTC buckets, returning at most 1000 points.""" workspace_uuid = require_workspace_uuid(context) + from .monitoring import MonitoringService + + service = MonitoringService(ap) bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day' step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1) postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql' @@ -40,10 +45,11 @@ async def get_traffic_series( pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00' time_bucket = sqlalchemy.func.strftime(pattern, timestamp) conditions = [model.workspace_uuid == workspace_uuid] + conditions.append(service._monitoring_execution_condition(model, workspace_uuid, execution_statuses, mode)) if bot_ids: conditions.append(model.bot_id.in_(bot_ids)) if pipeline_ids: - conditions.append(model.pipeline_id.in_(pipeline_ids)) + conditions.append(service._monitoring_processor_condition(model, workspace_uuid, pipeline_ids)) if start_time is not None: conditions.append(timestamp >= start_time) if end_time is not None: diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 54568433c..22a1c86c0 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -68,6 +68,11 @@ class PipelineService: runner_config['id'] = selected.id runner_config.setdefault('expire-time', 0) ai_config['runner_config'] = {selected.id: self._get_default_values_from_schema(selected.config_schema)} + ai_config['runner_config'][selected.id] = await self.ap.llm_model_service.apply_default_model( + context, + selected.config_schema, + ai_config['runner_config'][selected.id], + ) return config async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]: @@ -168,7 +173,9 @@ class PipelineService: if 'config' in pipeline_data: RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Check limitation - limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + from .workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, workspace_uuid) max_pipelines = limitation.get('max_pipelines', -1) if max_pipelines >= 0: existing_pipelines = await self.get_pipelines(context) @@ -285,7 +292,9 @@ class PipelineService: """Copy a pipeline with all its configurations""" workspace_uuid = require_workspace_uuid(context) # Check limitation - limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + from .workspace_limits import workspace_limits + + limitation = await workspace_limits(self.ap, workspace_uuid) max_pipelines = limitation.get('max_pipelines', -1) if max_pipelines >= 0: existing_pipelines = await self.get_pipelines(context) diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py index f51e5b609..5a3dc296a 100644 --- a/src/langbot/pkg/api/http/service/pipeline_migration.py +++ b/src/langbot/pkg/api/http/service/pipeline_migration.py @@ -152,6 +152,28 @@ def _json(value): return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str) +_SKIPPED_STATES = ('already_current', 'not_legacy', 'unsupported_runner') + + +def _version_at_least(installed, minimum): + # Pins are reviewed minimums; never downgrade an installed newer plugin. + from packaging.version import InvalidVersion, Version + + if installed == minimum: + return True + try: + return Version(str(installed)) >= Version(str(minimum)) + except (InvalidVersion, TypeError): + return False + + +def _candidate(row, plan): + candidate = {**row, 'config': plan.get('config')} + if plan.get('extensions_preferences') is not None: + candidate['extensions_preferences'] = plan['extensions_preferences'] + return candidate + + def _fingerprint(row): # SQL timestamps are neither monotonic revisions nor portable CAS values. return hashlib.sha256( @@ -312,7 +334,7 @@ class PipelineMigrationService: pending = dict(snapshots[0]._mapping) source = pending['source_snapshot'] plan = plan_legacy_pipeline(source['config'], source['extensions_preferences']) - candidate = {**row, 'config': plan.get('config')} + candidate = _candidate(row, plan) if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row): raise MigrationError('planner_changed') facts = await self._plugin(ctx, plan) @@ -439,7 +461,7 @@ class PipelineMigrationService: sources = [] for row in await self._rows(ctx): _, _, _, state, _ = await self._plan(ctx, row) - if state not in ('already_current', 'not_legacy'): + if state not in _SKIPPED_STATES: sources.append(row) if not sources: raise MigrationError('nothing_to_migrate') @@ -482,8 +504,11 @@ class PipelineMigrationService: hard_blockers = [b for b in blockers if b['code'] not in ('plugin_missing', 'plugin_disabled')] if hard_blockers: raise MigrationError(hard_blockers[0]['code']) - if state in ('already_current', 'not_legacy'): - result.update(state='already_current', code=None) + if state in _SKIPPED_STATES: + result.update( + state='unsupported_runner' if state == 'unsupported_runner' else 'already_current', + code=None, + ) continue target = plan.get('target_plugin') if not target: @@ -505,7 +530,7 @@ class PipelineMigrationService: facts and facts['enabled'] and descriptor - and descriptor.plugin_version == target['version'] + and _version_at_least(descriptor.plugin_version, target['version']) ) if not ready: task_context.metadata['phase'] = 'installing' @@ -516,15 +541,22 @@ class PipelineMigrationService: try: await self._authorize(ctx) await self.ap.plugin_connector.require_workspace_context(execution) - await self.ap.plugin_connector.install_plugin( - PluginInstallSource.MARKETPLACE, - { - 'plugin_author': target['author'], - 'plugin_name': target['name'], - 'plugin_version': target['version'], - }, - task_context=install_context, - ) + spec = {'plugin_author': target['author'], 'plugin_name': target['name']} + try: + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, + {**spec, 'plugin_version': target['version']}, + task_context=install_context, + ) + except (MarketplacePluginVersionNotFoundError, httpx.HTTPStatusError) as exc: + if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code != 404: + raise + # The reviewed pin was withdrawn: fall back to the + # marketplace latest; _verify_runtime still checks + # the schema against the planned config. + await self.ap.plugin_connector.install_plugin( + PluginInstallSource.MARKETPLACE, spec, task_context=install_context + ) install_code = None except PluginRuntimeNotConnectedError: install_code = 'plugin_runtime_unavailable' @@ -595,7 +627,7 @@ class PipelineMigrationService: descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None) if descriptor is None or 'agent' not in descriptor.usages: raise MigrationError('runner_unavailable') - if descriptor.plugin_version != plan['target_plugin']['version']: + if not _version_at_least(descriptor.plugin_version, plan['target_plugin']['version']): raise MigrationError('plugin_version_incompatible') config = plan['config'] RunnerConfigResolver.validate_pipeline_config(config) @@ -685,12 +717,23 @@ class PipelineMigrationService: ) async def _verify_resources(self, execution, descriptor, runner_config): - """Fail closed on references the scoped Host cannot authorize. + """Fail closed on unauthorized grants; prune dangling references. This runs only on explicit execution, outside the mutation transaction. Resource use is still reauthorized by the Host on every eventual run. """ + async def has_row(model, identifier, *, column=None): + column = model.uuid if column is None else column + return ( + await self.pm.execute_async( + sa.select(model.uuid).where( + model.workspace_uuid == execution.workspace_uuid, + column == identifier, + ) + ) + ).first() is not None + async def require_row(model, identifier, *, column=None): column = model.uuid if column is None else column found = ( @@ -715,20 +758,26 @@ class PipelineMigrationService: if value and value not in config_schema.NONE_SENTINELS: await require_row(EmbeddingModel, value) + # Dangling knowledge base / tool / MCP references (deleted in 4.10) + # are pruned rather than blocking the migration. kb_ids = runner_config.get('knowledge-bases', []) - if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids): - raise MigrationError('runner_resource_unavailable') + if not isinstance(kb_ids, list): + kb_ids = [] + kb_ids = [v for v in kb_ids if isinstance(v, str) and v and await has_row(KnowledgeBase, v)] + if 'knowledge-bases' in runner_config: + runner_config['knowledge-bases'] = kb_ids if kb_ids: if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr( permissions, 'knowledge_bases', [] ): raise MigrationError('runner_resource_unavailable') - for kb_id in kb_ids: - await require_row(KnowledgeBase, kb_id) tool_names = runner_config.get('tools', []) - if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names): - raise MigrationError('runner_resource_unavailable') + if not isinstance(tool_names, list): + tool_names = [] + tool_names = [v for v in tool_names if isinstance(v, str) and v] + if 'tools' in runner_config: + runner_config['tools'] = tool_names tool_grant = ( runner_config.get('enable-all-tools', False) or tool_names @@ -745,20 +794,22 @@ class PipelineMigrationService: descriptor, runner_config, ) - if not set(tool_names) <= {tool['tool_name'] for tool in tools}: - raise MigrationError('runner_resource_unavailable') + available = {tool['tool_name'] for tool in tools} + if 'tools' in runner_config: + runner_config['tools'] = [v for v in tool_names if v in available] - for attachment in runner_config.get('mcp-resources', []): - if not isinstance(attachment, dict): - raise MigrationError('runner_resource_unavailable') - identifier = attachment.get('server_uuid') or attachment.get('server_id') - name = attachment.get('server_name') - if identifier: - await require_row(MCPServer, identifier) - elif name: - await require_row(MCPServer, name, column=MCPServer.name) - else: - raise MigrationError('runner_resource_unavailable') + if 'mcp-resources' in runner_config: + kept = [] + for attachment in runner_config.get('mcp-resources') or []: + if not isinstance(attachment, dict): + continue + identifier = attachment.get('server_uuid') or attachment.get('server_id') + name = attachment.get('server_name') + if (identifier and await has_row(MCPServer, identifier)) or ( + not identifier and name and await has_row(MCPServer, name, column=MCPServer.name) + ): + kept.append(attachment) + runner_config['mcp-resources'] = kept async def _lock(self, ctx, pipeline_uuid, session): # Every pending producer shares this row and revalidates its captured @@ -792,11 +843,15 @@ class PipelineMigrationService: if pending: return pending['uuid'], pending['target_fingerprint'] target = {**row, 'config': candidate['config']} + values = {'config': candidate['config']} + if plan.get('extensions_preferences') is not None: + target['extensions_preferences'] = plan['extensions_preferences'] + values['extensions_preferences'] = plan['extensions_preferences'] t = LegacyPipeline.__table__ cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']] for key in ('config', 'stages', 'extensions_preferences'): cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key]) - result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config'])) + result = await self.pm.execute_async(sa.update(t).where(*cas).values(**values)) if result.rowcount != 1: raise MigrationError('preview_stale') snapshot_uuid = str(uuid.uuid4()) @@ -861,6 +916,8 @@ class PipelineMigrationService: RunnerConfigResolver.validate_pipeline_config(plan['config']) candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')} candidate_entity['config'] = copy.deepcopy(plan['config']) + if plan.get('extensions_preferences') is not None: + candidate_entity['extensions_preferences'] = copy.deepcopy(plan['extensions_preferences']) runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity)) if not data_only and await self._verify_runtime(execution, plan) != runtime_schema: raise MigrationError('runner_schema_changed') diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index be09c7849..bc613e7dc 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -11,7 +11,6 @@ from typing import Optional from urllib.parse import quote, unquote, urlparse import httpx -from langbot_plugin.entities.io.errors import ActionCallTimeoutError from ....core import app from ....skill.utils import parse_frontmatter @@ -26,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = ( 'description', 'instructions', 'package_root', + 'revision', 'created_at', 'updated_at', ) @@ -54,38 +54,11 @@ class SkillService: 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) + def _repository(self): + repository = getattr(self.ap, 'skill_repository', None) + if repository is None: + raise ValueError('Skill repository is not initialised') + return repository async def _execution_context(self, context: TenantContext) -> ExecutionContext: workspace_uuid = require_workspace_uuid(context) @@ -114,27 +87,11 @@ class SkillService: async def list_skills(self, context: TenantContext) -> list[dict]: execution_context = await self._execution_context(context) - # 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 [] - try: - skills = await box_service.list_skills(execution_context) - except ActionCallTimeoutError as exc: - logger = getattr(self.ap, 'logger', None) - if logger is not None: - logger.warning(f'Box skill listing timed out: {exc}') - return [] - return [self._serialize_skill(skill) for skill in skills] + return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)] async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]: execution_context = await self._execution_context(context) - box_service = self._box_service() - if box_service is None: - return None - skill = await box_service.get_skill(execution_context, skill_name) + skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True) return self._serialize_skill(skill) if skill else None async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]: @@ -142,22 +99,44 @@ class SkillService: async def create_skill(self, context: TenantContext, data: dict) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Creating a skill') - created = await box_service.create_skill(execution_context, data) + created = await self._repository().create_skill(execution_context, data) + await self._reload_skills(execution_context) + return self._serialize_skill(created) + + async def import_skill_directory( + self, + context: TenantContext, + path: str, + data: dict, + *, + base_revision: str | None = None, + ) -> dict: + execution_context = await self._execution_context(context) + created = await self._repository().import_skill_directory( + execution_context, + path, + data, + base_revision=base_revision, + ) await self._reload_skills(execution_context) return self._serialize_skill(created) async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Editing a skill') - updated = await box_service.update_skill(execution_context, skill_name, data) + payload = dict(data) + base_revision = str(payload.pop('base_revision', '') or '').strip() or None + updated = await self._repository().update_skill( + execution_context, + skill_name, + payload, + base_revision=base_revision, + ) await self._reload_skills(execution_context) return self._serialize_skill(updated) async def delete_skill(self, context: TenantContext, skill_name: str) -> bool: execution_context = await self._execution_context(context) - box_service = self._require_box('Deleting a skill') - await box_service.delete_skill(execution_context, skill_name) + await self._repository().delete_skill(execution_context, skill_name) await self._reload_skills(execution_context) return True @@ -170,24 +149,41 @@ class SkillService: max_entries: int = 200, ) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Browsing skill files') - return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries) + return await self._repository().list_skill_files( + execution_context, + skill_name, + path, + include_hidden, + max_entries, + ) async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Reading a skill file') - return await box_service.read_skill_file(execution_context, skill_name, path) + return await self._repository().read_skill_file(execution_context, skill_name, path) - async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict: + async def write_skill_file( + self, + context: TenantContext, + skill_name: str, + path: str, + content: str, + *, + base_revision: str | None, + ) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Editing skill files') - result = await box_service.write_skill_file(execution_context, skill_name, path, content) + result = await self._repository().write_skill_file( + execution_context, + skill_name, + path, + content, + base_revision=base_revision, + ) await self._reload_skills(execution_context) return result async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]: execution_context = await self._execution_context(context) - box_service = self._require_box('Installing a skill from GitHub') + repository = self._repository() owner = str(data['owner']).strip() repo = str(data['repo']).strip() release_tag = str(data.get('release_tag', '')).strip() @@ -206,7 +202,7 @@ class SkillService: 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( + installed = await repository.install_skill_zip( execution_context, zip_bytes, filename, @@ -219,7 +215,7 @@ class SkillService: async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]: execution_context = await self._execution_context(context) - box_service = self._require_box('Previewing a skill from GitHub') + repository = self._repository() owner = str(data['owner']).strip() repo = str(data['repo']).strip() release_tag = str(data.get('release_tag', '')).strip() @@ -236,7 +232,7 @@ class SkillService: 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( + return await repository.preview_skill_zip( execution_context, zip_bytes, f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip', @@ -253,8 +249,7 @@ class SkillService: source_path: str = '', ) -> list[dict]: execution_context = await self._execution_context(context) - box_service = self._require_box('Installing a skill from upload') - installed = await box_service.install_skill_zip( + installed = await self._repository().install_skill_zip( execution_context, file_bytes, filename, @@ -272,8 +267,7 @@ class SkillService: filename: str, ) -> list[dict]: execution_context = await self._execution_context(context) - box_service = self._require_box('Previewing a skill upload') - return await box_service.preview_skill_zip(execution_context, file_bytes, filename) + return await self._repository().preview_skill_zip(execution_context, file_bytes, filename) async def _install_github_skill_md( self, @@ -284,14 +278,14 @@ class SkillService: repo: str, data: dict, ) -> list[dict]: - box_service = self._require_box('Installing a skill from GitHub') + repository = self._repository() 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( + installed = await repository.install_skill_zip( context, zip_bytes, filename, @@ -310,13 +304,13 @@ class SkillService: owner: str, repo: str, ) -> list[dict]: - box_service = self._require_box('Previewing a skill from GitHub') + repository = self._repository() 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(context, zip_bytes, f'{package_name}.zip', target_suffix='') + return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='') async def reload_skills(self, context: TenantContext) -> list[dict]: execution_context = await self._execution_context(context) @@ -325,8 +319,7 @@ class SkillService: async def scan_directory_async(self, context: TenantContext, path: str) -> dict: execution_context = await self._execution_context(context) - box_service = self._require_box('Scanning a skill directory') - return await box_service.scan_skill_directory(execution_context, path) + return await self._repository().scan_skill_directory(execution_context, path) async def _reload_skills(self, context: TenantContext) -> None: skill_mgr = getattr(self.ap, 'skill_mgr', None) diff --git a/src/langbot/pkg/api/http/service/slack_setup.py b/src/langbot/pkg/api/http/service/slack_setup.py new file mode 100644 index 000000000..cc6938920 --- /dev/null +++ b/src/langbot/pkg/api/http/service/slack_setup.py @@ -0,0 +1,353 @@ +"""Short-lived Slack app provisioning and OAuth handoff sessions.""" + +import asyncio +import hashlib +import hmac +import ipaddress +import json +import re +import secrets +import time +from urllib.parse import urlencode, urlsplit + +import aiohttp + +from langbot.pkg.utils import httpclient + + +class SlackSetupError(ValueError): + pass + + +def callback_url(webhook_url: str, bot_uuid: str) -> str: + parsed = urlsplit(webhook_url) + suffix = f'/bots/{bot_uuid}' + if ( + parsed.scheme != 'https' + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or not parsed.path.endswith(suffix) + or parsed.hostname.lower() == 'localhost' + ): + raise SlackSetupError('invalid_webhook_url') + try: + if not ipaddress.ip_address(parsed.hostname).is_global: + raise SlackSetupError('invalid_webhook_url') + except ValueError as exc: + if isinstance(exc, SlackSetupError): + raise + return webhook_url[: -len(suffix)] + '/api/v1/platform/adapters/slack/setup/callback' + + +def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, socket_mode=False) -> dict: + settings = {'socket_mode_enabled': socket_mode} + if events: + settings['event_subscriptions'] = { + **({} if socket_mode else {'request_url': webhook_url}), + 'bot_events': [ + 'app_mention', + 'message.im', + 'message.channels', + 'message.groups', + 'message.mpim', + 'reaction_added', + 'reaction_removed', + 'member_joined_channel', + 'member_left_channel', + 'channel_rename', + 'group_rename', + 'channel_archive', + 'channel_unarchive', + 'group_archive', + 'group_unarchive', + 'app_home_opened', + 'app_uninstalled', + 'tokens_revoked', + ], + } + settings['interactivity'] = { + 'is_enabled': True, + **({} if socket_mode else {'request_url': webhook_url}), + } + return { + 'display_information': {'name': name}, + 'features': { + 'bot_user': {'display_name': name, 'always_online': False}, + 'app_home': {'messages_tab_enabled': True, 'messages_tab_read_only_enabled': False}, + }, + 'oauth_config': { + 'redirect_urls': [redirect_url], + 'scopes': { + 'bot': [ + 'app_mentions:read', + 'reactions:read', + 'chat:write', + 'channels:history', + 'channels:read', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'users:read', + 'files:read', + 'files:write', + ] + }, + }, + 'settings': settings, + } + + +class SlackSetupService: + TTL = 900 + + def __init__(self, ap): + self.ap = ap + self.sessions = {} + + @staticmethod + def scope(context): + return (context.instance_uuid, context.workspace_uuid, context.placement_generation, context.principal) + + def owned(self, context, session_id): + session = self.sessions.get(session_id) + if not session or session['scope'] != self.scope(context): + raise SlackSetupError('session_not_found') + return session + + def discard(self, session_id): + session = self.sessions.pop(session_id, None) + if session: + session['timer'].cancel() + task = session.get('task') + if task and not task.done(): + task.cancel() + session.clear() + + async def api(self, method, token=None, **params): + headers = {'Authorization': f'Bearer {token}'} if token else {} + try: + async with httpclient.get_session().post( + f'https://slack.com/api/{method}', + data=params, + headers=headers, + timeout=aiohttp.ClientTimeout(total=25), + allow_redirects=False, + ) as response: + if response.status != 200: + raise SlackSetupError(f'slack_http_{response.status}') + data = await response.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + raise SlackSetupError('slack_connection_failed') from exc + if not data.get('ok'): + # Never surface arbitrary upstream text or credentials in errors. + code = str(data.get('error', 'unknown_error')) + if not re.fullmatch(r'[a-z_]{1,80}', code): + code = 'unknown_error' + raise SlackSetupError(code) + return data + + async def start( + self, + context, + *, + bot_uuid, + access_token, + refresh_token, + webhook_url='', + name='LangBot', + socket_mode=False, + redirect_url='', + ): + if not all(isinstance(value, str) for value in (bot_uuid, access_token, refresh_token, webhook_url, name)): + raise SlackSetupError('invalid_input') + access_token, refresh_token, webhook_url, name = ( + access_token.strip(), + refresh_token.strip(), + webhook_url.strip(), + name.strip(), + ) + if not access_token or not refresh_token or not 1 <= len(name) <= 35: + raise SlackSetupError('invalid_input') + if not isinstance(socket_mode, bool): + raise SlackSetupError('invalid_input') + if socket_mode: + parsed = urlsplit(redirect_url) + if ( + not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or not parsed.path.endswith('/api/v1/platform/adapters/slack/setup/callback') + or not (parsed.scheme == 'https' or (parsed.scheme == 'http' and parsed.hostname == 'localhost')) + ): + raise SlackSetupError('invalid_redirect_url') + redirect = redirect_url + else: + redirect = callback_url(webhook_url, bot_uuid) + bot = await self.ap.bot_service.get_bot(context, bot_uuid) + if not bot or bot['adapter'] not in ('slack', 'slack-omni'): + raise SlackSetupError('slack_bot_not_found') + if ( + len(self.sessions) >= 100 + or sum(s['context'].workspace_uuid == context.workspace_uuid for s in self.sessions.values()) >= 10 + ): + raise SlackSetupError('too_many_sessions') + if any(s['bot_uuid'] == bot_uuid for s in self.sessions.values()): + raise SlackSetupError('setup_already_active') + sid = secrets.token_urlsafe(32) + session = { + 'scope': self.scope(context), + 'context': context, + 'status': 'creating', + 'bot_uuid': bot_uuid, + 'redirect_url': redirect, + 'webhook_url': webhook_url, + 'socket_mode': socket_mode, + 'timer': asyncio.get_running_loop().call_later(self.TTL, self.discard, sid), + } + self.sessions[sid] = session + coro = self._create(session, access_token, refresh_token, name, sid) + try: + wrapper = self.ap.task_mgr.create_user_task( + coro, + kind='platform-adapter-credential-exchange', + name=f'slack-setup-{sid}', + label='Slack app setup', + instance_uuid=context.instance_uuid, + workspace_uuid=context.workspace_uuid, + placement_generation=context.placement_generation, + ) + session['task'] = wrapper.task + except Exception: + coro.close() + self.discard(sid) + raise SlackSetupError('too_many_sessions') from None + return {'session_id': sid} + + async def _create(self, session, access, refresh, name, sid): + async def config_api(method, **params): + nonlocal access, refresh + try: + return await self.api(method, access, **params) + except SlackSetupError as exc: + if str(exc) not in ('token_expired', 'invalid_auth', 'token_revoked'): + raise + rotated = await self.api('tooling.tokens.rotate', refresh_token=refresh) + access, refresh = rotated['token'], rotated['refresh_token'] + return await self.api(method, access, **params) + + try: + base = manifest( + name, session['webhook_url'], session['redirect_url'], events=False, socket_mode=session['socket_mode'] + ) + result = await config_api('apps.manifest.create', manifest=json.dumps(base)) + session['app_id'] = result['app_id'] + session['credentials'] = result['credentials'] + # Register the signing secret before Slack validates the final event URL. + complete = manifest( + name, session['webhook_url'], session['redirect_url'], events=True, socket_mode=session['socket_mode'] + ) + await config_api('apps.manifest.update', app_id=result['app_id'], manifest=json.dumps(complete)) + session['authorize_url'] = 'https://slack.com/oauth/v2/authorize?' + urlencode( + { + 'client_id': result['credentials']['client_id'], + 'scope': ','.join(complete['oauth_config']['scopes']['bot']), + 'redirect_uri': session['redirect_url'], + 'state': sid, + } + ) + session['status'] = 'waiting' + except Exception as exc: + if session: + session['status'] = 'error' + session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'setup_failed' + session.pop('credentials', None) + finally: + access = refresh = '' + + def status(self, context, sid): + session = self.owned(context, sid) + result = {key: session[key] for key in ('status', 'app_id', 'authorize_url', 'error') if key in session} + if session['status'] == 'success': + result['config'] = session['config'] + return result + + async def finish(self, sid, code, error=None): + session = self.sessions.get(sid) + if not session or session['status'] != 'waiting': + raise SlackSetupError('session_not_found') + session['status'] = 'authorizing' + try: + if error or not code: + raise SlackSetupError('authorization_denied') + context = session['context'] + await self.ap.workspace_service.get_execution_binding( + context.workspace_uuid, + expected_generation=context.placement_generation, + ) + creds = session['credentials'] + result = await self.api( + 'oauth.v2.access', + client_id=creds['client_id'], + client_secret=creds['client_secret'], + code=code, + redirect_uri=session['redirect_url'], + ) + if result.get('app_id') != session['app_id'] or result.get('token_type') != 'bot': + raise SlackSetupError('unexpected_oauth_response') + session['config'] = { + 'bot_token': result['access_token'], + 'signing_secret': creds['signing_secret'], + 'socket_mode': session['socket_mode'], + } + session['status'] = 'success' + except Exception as exc: + if session: + session['status'] = 'error' + session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'authorization_failed' + raise SlackSetupError('authorization_failed') from None + finally: + session.pop('credentials', None) + session.pop('authorize_url', None) + + async def challenge(self, bot_uuid, request): + session = next((s for s in self.sessions.values() if s['bot_uuid'] == bot_uuid and s.get('credentials')), None) + if not session: + return None + body = await request.get_data() + timestamp = request.headers.get('X-Slack-Request-Timestamp', '') + if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: + return None + signature = ( + 'v0=' + + hmac.new( + session['credentials']['signing_secret'].encode(), + b'v0:' + timestamp.encode() + b':' + body, + hashlib.sha256, + ).hexdigest() + ) + if not hmac.compare_digest(signature, request.headers.get('X-Slack-Signature', '')): + return None + data = await request.get_json(silent=True) + if not isinstance(data, dict) or data.get('type') != 'url_verification': + return None + context = session['context'] + await self.ap.workspace_service.get_execution_binding( + context.workspace_uuid, + expected_generation=context.placement_generation, + ) + return {'challenge': data.get('challenge', '')} + + +def get_slack_setup(ap): + if not hasattr(ap, 'slack_setup_service'): + ap.slack_setup_service = SlackSetupService(ap) + return ap.slack_setup_service diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index b07f4f1ed..c95713f56 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -290,7 +290,23 @@ class SpaceService: selection = await self.get_model_selection('chat') if not selection: raise ValueError('No recommended chat model is available') - recommended = selection[0] + return await self._resolve_recommended_model(context, selection[0]) + + async def get_recommended_assistant_model(self, context: typing.Any) -> dict: + """Resolve the dedicated, health-filtered assistant recommendation without fallback.""" + space_url = self._get_space_config()['url'] + session = httpclient.get_session() + async with session.get(f'{space_url}/api/v1/models/assistant/recommendation') as response: + if response.status != 200: + raise ValueError('No recommended assistant model is available') + payload = await httpclient.read_json_limited(response) + if payload.get('code') != 0: + raise ValueError('No recommended assistant model is available') + recommended = SpaceModel.model_validate(payload['data']['model']) + return await self._resolve_recommended_model(context, recommended) + + async def _resolve_recommended_model(self, context: typing.Any, recommended: SpaceModel) -> dict: + """Map a public catalog model to this Workspace, refreshing the catalog once.""" async def find_local_model(): result = await self.ap.persistence_mgr.execute_async( diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 5cd5766d0..60926178f 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import sqlalchemy import argon2 import jwt @@ -423,12 +425,12 @@ class UserService: user_obj = await self.get_user_by_email(user_email) if user_obj is None: - raise ValueError('用户不存在') + raise ValueError(t('Resource not found')) self._require_active_account(user_obj) # Check if this user has a local password set if not user_obj.password: - raise ValueError('请使用 LangBot 账号登录') + raise ValueError(t('Please sign in with your LangBot Account')) await self._verify_password(user_obj.password, password) diff --git a/src/langbot/pkg/api/http/service/workspace_limits.py b/src/langbot/pkg/api/http/service/workspace_limits.py new file mode 100644 index 000000000..dc41a2a2b --- /dev/null +++ b/src/langbot/pkg/api/http/service/workspace_limits.py @@ -0,0 +1,20 @@ +"""Resolve resource limits at the Workspace service boundary.""" + +from ....cloud.entitlements import EntitlementUnavailableError + +LIMIT_KEYS = { + 'max_bots': 'bots.max', + 'max_pipelines': 'pipelines.max', + 'max_extensions': 'plugins.max', + 'max_knowledge_bases': 'knowledge_bases.max', +} + + +async def workspace_limits(ap, workspace_uuid: str, *, minimum_revision: int = 0) -> dict[str, int]: + if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud': + return dict(ap.instance_config.data.get('system', {}).get('limitation', {})) + resolver = getattr(ap, 'entitlement_resolver', None) + if resolver is None: + raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable') + snapshot = await resolver.resolve(workspace_uuid, minimum_revision=minimum_revision) + return {config_key: snapshot.limit(limit_key) for config_key, limit_key in LIMIT_KEYS.items()} diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 58cc9be71..527bd00d8 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -19,11 +19,14 @@ from __future__ import annotations import json import typing +import functools from mcp.server.fastmcp import FastMCP from ..http.authz import Permission, require_permission from .context import get_request_context +from ...i18n import language_scope +from ...i18n.workspace import get_workspace_language, set_workspace_language if typing.TYPE_CHECKING: from ...core import app as app_module @@ -78,6 +81,20 @@ class LangBotMCPServer: ap = self.ap mcp = self + @mcp.tool(description='Get the Workspace language used for backend messages and default bot replies.') + async def get_workspace_message_language() -> str: + context = _authorized(Permission.WORKSPACE_VIEW) + return _dump({'language': await get_workspace_language(ap, context.workspace_uuid, refresh=True)}) + + @mcp.tool( + description='Set the Workspace message language. Requires workspace.update; does not change personal UI language.' + ) + async def set_workspace_message_language( + language: typing.Literal['en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN'], + ) -> str: + context = _authorized(Permission.WORKSPACE_UPDATE) + return _dump({'language': await set_workspace_language(ap, context.workspace_uuid, language)}) + # ----- System (read-only) -------------------------------------- # @mcp.tool(description='Get basic LangBot system/runtime information (version, edition).') async def get_system_info() -> str: @@ -165,7 +182,7 @@ class LangBotMCPServer: return _dump(ap.box_service.get_recent_errors(context)) @mcp.tool( - description='Read bounded Workspace runtime records. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, knowledge_base_id, user_query, is_active as supported by the record kind.' + description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.' ) async def get_monitoring_records( kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'], @@ -176,7 +193,7 @@ class LangBotMCPServer: import datetime context = _authorized(Permission.RESOURCE_VIEW) - allowed = {'start_time', 'end_time'} + allowed = {'start_time', 'end_time', 'mode', 'execution_statuses'} if kind != 'embedding_calls': allowed |= {'bot_ids', 'pipeline_ids'} if kind in {'messages', 'tool_calls'}: @@ -207,6 +224,116 @@ class LangBotMCPServer: return _dump(await ap.monitoring_service.get_message_details(context, identifier)) return _dump(await ap.monitoring_service.get_session_analysis(context, identifier)) + @mcp.tool( + description='Read a bounded snapshot of active and recently started Workspace executions, including the latest progress event. No percentage is estimated.' + ) + async def get_inflight_executions() -> str: + return _dump(await ap.monitoring_service.get_inflight_snapshot(_authorized(Permission.RESOURCE_VIEW))) + + @mcp.tool( + description='Reset a bot session model context, preserving monitoring history. Refuses busy sessions.' + ) + async def reset_session_context(bot_id: str, session_id: str) -> str: + return _dump( + await ap.monitoring_service.reset_session_context( + _authorized(Permission.RESOURCE_MANAGE), + bot_id, + session_id, + ) + ) + + @mcp.tool( + description='Create/configure a Slack app for an existing Workspace bot draft. Returns a setup session; poll it for the installation authorization URL. The user must approve Slack installation. Configuration tokens stay in memory only.' + ) + async def start_slack_setup( + bot_uuid: str, + access_token: str, + refresh_token: str, + webhook_url: str = '', + name: str = 'LangBot', + socket_mode: bool = False, + redirect_url: str = '', + ) -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + + return _dump( + await get_slack_setup(ap).start( + _authorized(Permission.RESOURCE_MANAGE), + bot_uuid=bot_uuid, + access_token=access_token, + refresh_token=refresh_token, + webhook_url=webhook_url, + name=name, + socket_mode=socket_mode, + redirect_url=redirect_url, + ) + ) + + @mcp.tool( + description='Read an owned Slack setup session. Success includes adapter credentials; save them with the existing bot update tool. Socket Mode additionally needs an App-Level Token with connections:write.' + ) + async def get_slack_setup_status(session_id: str) -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + + return _dump(get_slack_setup(ap).status(_authorized(Permission.RESOURCE_MANAGE), session_id)) + + @mcp.tool( + description='Discard an owned Slack setup session and its temporary credentials. Does not delete the Slack app.' + ) + async def cancel_slack_setup(session_id: str) -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + + service = get_slack_setup(ap) + service.owned(_authorized(Permission.RESOURCE_MANAGE), session_id) + service.discard(session_id) + return _dump({'cancelled': True}) + + @mcp.tool( + description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.' + ) + async def get_monitoring_executions( + limit: int = 50, + offset: int = 0, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + statuses: list[str] | None = None, + mode: typing.Literal['all', 'real', 'debug'] = 'all', + ) -> str: + context = _authorized(Permission.RESOURCE_VIEW) + return _dump( + await ap.monitoring_service.get_executions( + context, + limit=limit, + offset=offset, + bot_ids=bot_ids, + pipeline_ids=pipeline_ids, + statuses=statuses, + mode=mode, + ) + ) + + @mcp.tool( + description='Read execution inputs, generated outputs, delivery records, calls, errors, related executions and conversation history. Use section=overview for inputs, outputs, deliveries and errors without loading history; request other sections on demand. Omit section for the full trace. Each section is independently paginated; follow pages[section].next_offset when has_more is true.' + ) + async def get_monitoring_execution_detail( + source: typing.Literal['auto', 'agent', 'pipeline', 'event'], + identifier: str, + section: str | None = None, + offset: int = 0, + limit: int = 100, + ) -> str: + context = _authorized(Permission.RESOURCE_VIEW) + return _dump( + await ap.monitoring_service.get_execution_detail( + context, + source, + identifier, + section=section, + offset=offset, + limit=limit, + ) + ) + # ----- Bots ---------------------------------------------------- # @mcp.tool(description='List all messaging-platform bots. Secrets are redacted.') async def list_bots() -> str: @@ -220,7 +347,7 @@ class LangBotMCPServer: @mcp.tool( description=( - 'Create a bot. `bot_data` is a JSON object matching the LangBot ' + 'Create a bot. Cloud Workspace platform restrictions also apply to this tool. `bot_data` is a JSON object matching the LangBot ' 'POST /api/v1/platform/bots body (e.g. name, adapter, config). ' 'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array ' 'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. ' @@ -378,12 +505,38 @@ class LangBotMCPServer: context = _authorized(Permission.RUNTIME_OPERATE) return _dump(await ap.agent_service.debug_agent(context, processor_uuid, payload)) + @mcp.tool(description='Get the single starred LLM model UUID for the current Workspace.') + async def get_starred_model() -> str: + context = _authorized(Permission.RESOURCE_VIEW) + return _dump({'uuid': await ap.llm_model_service.get_starred_model(context)}) + + @mcp.tool( + description='Star one LLM model for this Workspace, replacing the previous choice. Pass null to clear.' + ) + async def set_starred_model(model_uuid: str | None = None) -> str: + context = _authorized(Permission.PROVIDER_SECRET_MANAGE) + await ap.llm_model_service.set_starred_model(context, model_uuid) + return _dump({'uuid': model_uuid}) + + @mcp.tool( + description='Resolve the default LLM model: starred model, then the available Space wizard recommendation.' + ) + async def get_default_model() -> str: + context = _authorized(Permission.RESOURCE_MANAGE) + return _dump({'uuid': await ap.llm_model_service.get_default_model(context)}) + @mcp.tool(description='List all configured LLM models. Secrets are redacted.') async def list_llm_models() -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False)) - @mcp.tool(description='Get a single LLM model by UUID.') + @mcp.tool( + description=( + 'Get a single LLM model by UUID, including reasoning capabilities. ' + 'Optional budget_tokens min/max describes numeric thinking-budget support; ' + 'reasoning_config and extra_args retain the existing model API shape.' + ) + ) async def get_llm_model(model_uuid: str) -> str: context = _authorized(Permission.RESOURCE_VIEW) return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False)) @@ -441,7 +594,12 @@ class LangBotMCPServer: """Register a tool boundary before its authorization and service call.""" def register(fn): - observed = fn + @functools.wraps(fn) + async def observed(*args, **kwargs): + context = get_request_context() + with language_scope(await get_workspace_language(self.ap, context.workspace_uuid)): + return await fn(*args, **kwargs) + return self.mcp.tool(**options)(observed) return register diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 90a16deda..c1771e2f6 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): - 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. + own filesystem view (container, pod sidecar, or remote host), so only + explicitly shared paths are usable on both sides. When False, Box runs + as a stdio child process that shares LangBot's filesystem. """ return bool( self.configured_runtime_endpoint diff --git a/src/langbot/pkg/box/runner.py b/src/langbot/pkg/box/runner.py index fa1b8aab6..6e88cb842 100644 --- a/src/langbot/pkg/box/runner.py +++ b/src/langbot/pkg/box/runner.py @@ -24,6 +24,7 @@ class RunBoxBinding: exported: dict = field(default_factory=dict) submitted: set = field(default_factory=set) lock: asyncio.Lock = field(default_factory=asyncio.Lock) + read_only_mounts: list[dict] = field(default_factory=list) def prepare_input_files(query, agent_input): @@ -152,9 +153,10 @@ class RunnerBoxService: if set(request.options) - {'image'}: raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned') spec = {'session_id': session_id, **request.options} - if query is not None: - spec['extra_mounts'] = self.box.build_skill_extra_mounts(query) - result = await self.box.create_session(context, spec) + from ..provider.tools.loaders.skill import build_execution_mounts + + mounts = build_execution_mounts(self.box.ap, query) if query is not None else [] + result = await self.box.create_session(context, spec, trusted_read_only_mounts=mounts) return self.public_session(result) async def bind(self, context, query, run_id, box_id): @@ -174,8 +176,11 @@ class RunnerBoxService: return self.binding_info(current) fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent') spec = {key: item[key] for key in fields if key in item} - spec['extra_mounts'] = self.box.build_skill_extra_mounts(query) - binding = RunBoxBinding(run_id, box_id, spec, run_id) + from ..provider.tools.loaders.skill import build_execution_mounts + + binding = RunBoxBinding( + run_id, box_id, spec, run_id, read_only_mounts=build_execution_mounts(self.box.ap, query) + ) object.__setattr__(query, '_box_binding', binding) return self.binding_info(binding) diff --git a/src/langbot/pkg/box/sandbox_files.py b/src/langbot/pkg/box/sandbox_files.py new file mode 100644 index 000000000..730260149 --- /dev/null +++ b/src/langbot/pkg/box/sandbox_files.py @@ -0,0 +1,139 @@ +"""Bounded, link-free outbox operations executed inside the bound sandbox.""" + +import os +import textwrap + + +def outbox_script( + outbox_root: str, + query_key: str, + *, + operation: str, + max_file_bytes: int, + max_files: int, + max_total_bytes: int, +) -> str: + settings = { + 'workspace': os.path.dirname(outbox_root), + 'outbox': os.path.basename(outbox_root), + 'query': query_key, + 'operation': operation, + 'max_file_bytes': max_file_bytes, + 'max_files': max_files, + 'max_total_bytes': max_total_bytes, + } + return f'settings = {settings!r}\n' + textwrap.dedent("""\ + import base64, errno, json, os, stat + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + out = [] + budget = {'entries': 512, 'directories': 64, 'bytes': 0} + + def component(value): + if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240 + or any(c in value for c in ('/', '\\\\', '\\x00'))): + raise RuntimeError('Unsafe sandbox outbox path component') + return value + + def read_directory(fd, prefix='', depth=0): + if depth > 8: + return + with os.scandir(fd) as entries: + for entry in entries: + if len(out) >= settings['max_files']: + return + if budget['entries'] <= 0: + raise RuntimeError('Sandbox outbox exceeds the directory-entry limit') + budget['entries'] -= 1 + name = component(entry.name) + relative = prefix + '/' + name if prefix else name + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + if budget['directories'] <= 0: + raise RuntimeError('Sandbox outbox exceeds the directory limit') + budget['directories'] -= 1 + flags = directory_flags + elif entry.is_file(follow_symlinks=False): + flags = file_flags + else: + continue + try: + child_fd = os.open(name, flags, dir_fd=fd) + except OSError as exc: + if exc.errno in {errno.ENOENT, errno.ELOOP, errno.ENOTDIR}: + continue + raise + try: + metadata = os.fstat(child_fd) + if flags == directory_flags and stat.S_ISDIR(metadata.st_mode): + read_directory(child_fd, relative, depth + 1) + continue + if not stat.S_ISREG(metadata.st_mode): + continue + remaining = settings['max_total_bytes'] - budget['bytes'] + if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining: + raise RuntimeError('Sandbox output attachment exceeds the export size limit') + with os.fdopen(child_fd, 'rb', closefd=False) as source: + data = source.read(settings['max_file_bytes'] + 1) + if len(data) > settings['max_file_bytes'] or len(data) > remaining: + raise RuntimeError('Sandbox output attachment exceeds the export size limit') + out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')}) + budget['bytes'] += len(data) + finally: + os.close(child_fd) + + def clear_directory(fd, depth=0): + if depth > 16: + raise RuntimeError('Sandbox cleanup exceeded its depth limit') + with os.scandir(fd) as entries: + for entry in entries: + if budget['entries'] <= 0: + raise RuntimeError('Sandbox cleanup exceeded its inode limit') + budget['entries'] -= 1 + name = component(entry.name) + try: + child_fd = os.open(name, directory_flags, dir_fd=fd) + except FileNotFoundError: + continue + except OSError as exc: + if exc.errno not in {errno.ELOOP, errno.ENOTDIR}: + raise + try: + os.unlink(name, dir_fd=fd) + except FileNotFoundError: + pass + else: + try: + clear_directory(child_fd, depth + 1) + finally: + os.close(child_fd) + try: + os.rmdir(name, dir_fd=fd) + except FileNotFoundError: + pass + + try: + workspace_fd = os.open(settings['workspace'], directory_flags) + try: + outbox_fd = os.open(component(settings['outbox']), directory_flags, dir_fd=workspace_fd) + try: + query_fd = os.open(component(settings['query']), directory_flags, dir_fd=outbox_fd) + try: + if settings['operation'] == 'read': + read_directory(query_fd) + elif settings['operation'] == 'clear': + budget['entries'] = 4096 + clear_directory(query_fd) + else: + raise RuntimeError('Unknown sandbox outbox operation') + finally: + os.close(query_fd) + finally: + os.close(outbox_fd) + finally: + os.close(workspace_fd) + except FileNotFoundError: + pass + print(json.dumps(out)) + """) diff --git a/src/langbot/pkg/box/secure_fs.py b/src/langbot/pkg/box/secure_fs.py index 4e6c7de3a..2a0d2166a 100644 --- a/src/langbot/pkg/box/secure_fs.py +++ b/src/langbot/pkg/box/secure_fs.py @@ -14,7 +14,9 @@ class UnsafeWorkspacePathError(OSError): _DIRECTORY_FLAGS = ( os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) ) -_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) +_FILE_READ_FLAGS = ( + os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0) +) _FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) _MAX_REMOVAL_ENTRIES = 4096 _MAX_REMOVAL_DEPTH = 16 @@ -45,6 +47,8 @@ def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathE def _root_fd(root: str): try: fd = os.open(root, _DIRECTORY_FLAGS) + except PermissionError: + raise except OSError as exc: raise _unsafe(root, exc) try: @@ -64,6 +68,8 @@ def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int: pass try: fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) + except PermissionError: + raise except OSError as exc: raise _unsafe(name, exc) if not stat.S_ISDIR(os.fstat(fd).st_mode): @@ -91,6 +97,8 @@ def _remove_entry( child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) except FileNotFoundError: return + except PermissionError: + raise except OSError as exc: if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}: raise @@ -198,6 +206,8 @@ def _read_directory( remaining_directories[0] -= 1 try: child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd) + except PermissionError: + raise except OSError: continue try: @@ -218,6 +228,8 @@ def _read_directory( continue try: file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd) + except PermissionError: + raise except OSError: continue try: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 4f51b15aa..c4c4552c1 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -20,7 +20,6 @@ from langbot_plugin.box.security import BOX_SHARED_WORKSPACE_PROBE_PREFIX from .admission import SandboxAdmissionController, require_cloud_admission_policy from .connector import BoxRuntimeConnector, _get_box_config from . import secure_fs -from ..telemetry import features as telemetry_features from ..utils import httpclient from ..api.http.context import ExecutionContext from ..api.http.service.tenant import TenantContext, require_workspace_uuid @@ -28,8 +27,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation from langbot_plugin.box.models import ( BUILTIN_PROFILES, BoxExecutionResult, + BoxHostMountMode, BoxManagedProcessInfo, BoxManagedProcessSpec, + BoxMountSpec, BoxProfile, BoxSpec, ) @@ -169,7 +170,7 @@ class BoxService: 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.' + 'stdio MCP, executable package scripts) will be unavailable.' ) return try: @@ -275,17 +276,6 @@ class BoxService: await self._purge_attachment_dirs() self._available = True self._connector_error = '' - skill_mgr = getattr(self.ap, 'skill_mgr', None) - reload_skills = getattr(skill_mgr, 'reload_skills', None) - if callable(reload_skills) and not self._cloud_managed: - binding = await self.ap.workspace_service.get_execution_binding() - await reload_skills( - ExecutionContext( - instance_uuid=binding.instance_uuid, - workspace_uuid=binding.workspace_uuid, - placement_generation=binding.placement_generation, - ) - ) self.ap.logger.info('Box runtime reconnected, sandbox features restored.') return except Exception as exc: @@ -365,19 +355,17 @@ class BoxService: """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. + LangBot (same container/host). In that case host paths resolve + identically on both sides and Core may perform local filesystem work. 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. + where only explicitly shared and identically mounted roots can cross + the process boundary. 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 + there is no connector to introspect; we conservatively report False. + 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: @@ -490,11 +478,18 @@ class BoxService: self, context: TenantContext, spec_payload: dict, + *, + trusted_read_only_mounts: list[dict] | None = None, ) -> dict: """Reject tenant-owned policy fields and apply the Cloud hard policy.""" payload = dict(spec_payload) + trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or []) if not self._cloud_managed: + if trusted_mounts: + if payload.get('extra_mounts'): + raise BoxValidationError('extra_mounts and trusted_read_only_mounts cannot both be supplied') + payload['extra_mounts'] = trusted_mounts return payload policy = self._admission_policy if policy is None: @@ -544,7 +539,7 @@ class BoxService: 'network': 'off', 'host_path': canonical_host_path, 'mount_path': '/workspace', - 'extra_mounts': [], + 'extra_mounts': trusted_mounts, 'persistent': True, 'timeout_sec': min(timeout, policy.max_timeout_sec), 'cpus': policy.cpus, @@ -556,6 +551,22 @@ class BoxService: ) return payload + def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]: + """Validate Core-composed artifacts before crossing into Box.""" + + normalized: list[dict] = [] + for raw_mount in mounts: + mount = BoxMountSpec.model_validate(raw_mount) + if mount.mode != BoxHostMountMode.READ_ONLY: + raise BoxAdmissionError('Core-composed additional mounts must be read-only') + host_path = os.path.realpath(mount.host_path) + if not os.path.isdir(host_path): + raise BoxAdmissionError('Core-composed read-only mount source is unavailable') + if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots): + raise BoxAdmissionError('Core-composed read-only mount source is outside allowed_mount_roots') + normalized.append(mount.model_copy(update={'host_path': host_path}).model_dump(mode='json')) + return normalized + def _reject_cloud_managed_process(self) -> None: if self._cloud_managed: raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes') @@ -577,7 +588,25 @@ class BoxService: query: pipeline_query.Query, *, skip_host_mount_validation: bool = False, + trusted_read_only_mounts: list[dict] | None = None, ) -> dict: + result = await self._execute_spec_payload_result( + spec_payload, + query, + skip_host_mount_validation=skip_host_mount_validation, + trusted_read_only_mounts=trusted_read_only_mounts, + ) + return self._serialize_result(result) + + async def _execute_spec_payload_result( + self, + spec_payload: dict, + query: pipeline_query.Query, + *, + skip_host_mount_validation: bool = False, + trusted_read_only_mounts: list[dict] | None = None, + ) -> BoxExecutionResult: + """Execute within the bound Workspace and preserve the raw result for file transfers.""" if not self._available: raise BoxError( 'Box runtime is not available. Configure an available Box backend before using Box features.' @@ -585,11 +614,17 @@ class BoxService: from .runner import binding_for binding = binding_for(query) + if trusted_read_only_mounts is None: + trusted_read_only_mounts = binding.read_only_mounts if spec_payload.get('session_id') not in (None, '', binding.session_id): raise BoxValidationError('session_id must match the bound Box') spec_payload = {**binding.spec, **spec_payload, 'session_id': binding.session_id} execution_context = await self._validated_execution_context(self._query_execution_context(query)) - spec_payload = self._managed_policy_payload(execution_context, spec_payload) + spec_payload = self._managed_policy_payload( + execution_context, + spec_payload, + trusted_read_only_mounts=trusted_read_only_mounts, + ) await self._require_validated_workspace_sandbox(execution_context) if spec_payload.get('host_path') in (None, ''): tenant_workspace = self._tenant_workspace(execution_context) @@ -636,8 +671,7 @@ class BoxService: 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) + return result def resolve_box_session_id(self, query: pipeline_query.Query) -> str: """Use the Box explicitly bound by the current Runner invocation.""" @@ -645,70 +679,12 @@ class BoxService: return binding_for(query).session_id - 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. - """ - if self._cloud_managed: - return [] - 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, *, - skill_name: str | None = None, + read_only_mounts: list[dict] | None = None, ) -> dict: """Execute an agent-facing ``exec`` tool call. @@ -716,8 +692,6 @@ class BoxService: ``BoxSpec.cmd`` field and injects the session id from the query. """ spec_payload: dict = {'cmd': parameters['command']} - if skill_name is not None: - spec_payload['skill_name'] = skill_name # Pass through allowed agent-facing fields for key in ('workdir', 'timeout_sec', 'env'): @@ -727,11 +701,11 @@ class BoxService: # 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) + return await self.execute_spec_payload( + spec_payload, + query, + trusted_read_only_mounts=read_only_mounts, + ) async def execute_in_context( self, @@ -813,6 +787,10 @@ class BoxService: 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. """ + # secure_fs relies on POSIX openat/O_NOFOLLOW. On Windows use the + # sandbox execution channel rather than misclassifying EACCES as a link. + if os.open not in os.supports_dir_fd or not hasattr(os, 'O_NOFOLLOW'): + return None root = self._tenant_workspace(self._query_execution_context(query)) if not root or not os.path.isdir(root) or os.path.islink(root): return None @@ -1151,12 +1129,10 @@ class BoxService: 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. + Read regular files within the configured attachment limits from the + current run's outbox. Private container output uses the bound sandbox + channel; invalid or truncated responses retain the files. Clear a + successful collection and return ``{type, name, base64}`` descriptors. """ if not self._available: return [] @@ -1165,7 +1141,11 @@ class BoxService: host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query) if host_dir is not None: - entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + try: + entries = await asyncio.to_thread(self._read_outbox_host, host_dir) + except PermissionError: + # Root-owned container output can be private to its sandbox UID. + entries = await self._read_outbox_via_exec(query) else: entries = await self._read_outbox_via_exec(query) @@ -1204,80 +1184,48 @@ class BoxService: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: """Fallback: read the outbox over the exec channel (E2B / remote). - Uses ``client.execute`` directly (bypassing ``_serialize_result``) - so stdout is NOT truncated by ``output_limit_chars`` - the raw - base64 payload can be far larger than the 4000-char display limit. + Use the same Workspace-scoped execution path as tools, retaining raw + stdout because base64 payloads can exceed the display truncation limit. """ import json as _json - target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}' - max_file_bytes = self._EXEC_FALLBACK_MAX_BYTES - max_files = self._ATTACHMENT_MAX_FILES - max_total_bytes = max_file_bytes * max_files - max_scan_entries = 1000 - script = ( - 'import base64, json, os\n' - f'target = {target_dir!r}\n' - f'max_file_bytes = {max_file_bytes}\n' - f'max_files = {max_files}\n' - f'max_total_bytes = {max_total_bytes}\n' - f'max_scan_entries = {max_scan_entries}\n' - 'out = []\n' - 'total_bytes = 0\n' - 'scanned_entries = 0\n' - 'stack = [target]\n' - 'if os.path.isdir(target):\n' - ' while stack and len(out) < max_files and scanned_entries < max_scan_entries:\n' - ' current = stack.pop()\n' - ' try:\n' - ' with os.scandir(current) as iterator:\n' - ' entries = sorted(iterator, key=lambda item: item.name, reverse=True)\n' - ' except OSError:\n' - ' continue\n' - ' for entry in entries:\n' - ' scanned_entries += 1\n' - ' if scanned_entries > max_scan_entries:\n' - ' break\n' - ' try:\n' - ' if entry.is_dir(follow_symlinks=False):\n' - ' stack.append(entry.path)\n' - ' continue\n' - ' if not entry.is_file(follow_symlinks=False):\n' - ' continue\n' - ' size = entry.stat(follow_symlinks=False).st_size\n' - ' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n' - ' continue\n' - " with open(entry.path, 'rb') as f:\n" - ' data = f.read(max_file_bytes + 1)\n' - ' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n' - ' continue\n' - ' except OSError:\n' - ' continue\n' - ' rel = os.path.relpath(entry.path, target)\n' - " out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n" - ' total_bytes += len(data)\n' - ' if len(out) >= max_files:\n' - ' break\n' - 'print(json.dumps(out))\n' + from .sandbox_files import outbox_script + + max_file_bytes = self._ATTACHMENT_MAX_BYTES + max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES + script = outbox_script( + self.OUTBOX_MOUNT_DIR, + self._attachment_query_key(query), + operation='read', + max_file_bytes=max_file_bytes, + max_files=self._ATTACHMENT_MAX_FILES, + max_total_bytes=max_total_bytes, ) spec_payload: dict = { 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120, 'session_id': self.resolve_box_session_id(query), } - if 'extra_mounts' not in spec_payload: - spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) - try: - spec = self.build_spec(spec_payload) - result = await self.client.execute(spec) - except Exception: - return [] + result = await self._execute_spec_payload_result(spec_payload, query) if not result.ok: - return [] + if 'Sandbox output attachment exceeds the export size limit' in (result.stderr or ''): + raise BoxValidationError( + f'Sandbox output attachments exceed the export limit ' + f'({max_file_bytes} bytes per file, {max_total_bytes} bytes total); files were retained' + ) + raise BoxValidationError(f'Failed to read sandbox outbox (exit code {result.exit_code})') try: - return _json.loads(str(result.stdout or '').strip().splitlines()[-1]) - except Exception: - return [] + entries = _json.loads(str(result.stdout or '').strip().splitlines()[-1]) + if not isinstance(entries, list) or any( + not isinstance(entry, dict) + or not isinstance(entry.get('name'), str) + or not isinstance(entry.get('b64'), str) + for entry in entries + ): + raise ValueError('Invalid attachment list') + return entries + except (ValueError, IndexError) as exc: + raise BoxValidationError('Sandbox outbox returned invalid attachment data') from exc async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None: """Empty the per-query outbox after collection. @@ -1302,19 +1250,37 @@ class BoxService: try: secure_fs.reset_directory(tenant_root, subdir, query_key) return False + except secure_fs.UnsafeWorkspacePathError: + raise except OSError: return True - survived = await asyncio.to_thread(_clear) + try: + survived = await asyncio.to_thread(_clear) + except secure_fs.UnsafeWorkspacePathError as exc: + self.ap.logger.warning(f'Failed to clear unsafe sandbox outbox {target_dir}: {exc}') + return 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}, + from .sandbox_files import outbox_script + + script = outbox_script( + self.OUTBOX_MOUNT_DIR, + self._attachment_query_key(query), + operation='clear', + max_file_bytes=self._ATTACHMENT_MAX_BYTES, + max_files=self._ATTACHMENT_MAX_FILES, + max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES, + ) + result = await self.execute_tool( + {'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 30}, query, ) + if not result.get('ok'): + raise BoxValidationError('Sandbox outbox cleanup failed') except Exception as exc: self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}') @@ -1422,9 +1388,12 @@ class BoxService: spec_payload: dict, *, skip_host_mount_validation: bool = False, + trusted_read_only_mounts: list[dict] | None = None, ) -> dict: execution_context = await self._validated_execution_context(context) - spec_payload = self._managed_policy_payload(execution_context, spec_payload) + spec_payload = self._managed_policy_payload( + execution_context, spec_payload, trusted_read_only_mounts=trusted_read_only_mounts + ) await self._require_validated_workspace_sandbox(execution_context) if spec_payload.get('host_path') in (None, ''): tenant_workspace = self._tenant_workspace(execution_context) @@ -1528,126 +1497,6 @@ class BoxService: self._runtime_connector.get_relay_headers(action_context), ) - async def list_skills(self, context: TenantContext) -> list[dict]: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.list_skills(action_context=self._action_context(execution_context)) - - async def get_skill(self, context: TenantContext, name: str) -> dict | None: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.get_skill(name, action_context=self._action_context(execution_context)) - - async def create_skill(self, context: TenantContext, skill: dict) -> dict: - execution_context = await self._validated_skill_execution_context(context) - payload = dict(skill) - payload.pop('workspace_uuid', None) - if self._cloud_managed and str(payload.get('package_root', '') or '').strip(): - raise BoxAdmissionError('Cloud skill package_root is runtime-owned') - if self._cloud_managed: - payload.pop('package_root', None) - return await self.client.create_skill(payload, action_context=self._action_context(execution_context)) - - async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict: - execution_context = await self._validated_skill_execution_context(context) - payload = dict(skill) - payload.pop('workspace_uuid', None) - if self._cloud_managed: - # The runtime already owns the package path for an existing skill. - # A serialized read response may contain it, but it is never an - # authority-bearing update field in shared Cloud mode. - payload.pop('package_root', None) - return await self.client.update_skill( - name, - payload, - action_context=self._action_context(execution_context), - ) - - async def delete_skill(self, context: TenantContext, name: str) -> None: - execution_context = await self._validated_skill_execution_context(context) - await self.client.delete_skill(name, action_context=self._action_context(execution_context)) - - async def scan_skill_directory(self, context: TenantContext, path: str) -> dict: - execution_context = await self._validated_skill_execution_context(context) - if self._cloud_managed: - raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud') - return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context)) - - async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext: - execution_context = await self._validated_execution_context(context) - await self._require_validated_workspace_sandbox(execution_context) - return execution_context - - async def list_skill_files( - self, - context: TenantContext, - name: str, - path: str = '.', - include_hidden: bool = False, - max_entries: int = 200, - ) -> dict: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.list_skill_files( - name, - path, - include_hidden, - max_entries, - action_context=self._action_context(execution_context), - ) - - async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.read_skill_file( - name, - path, - action_context=self._action_context(execution_context), - ) - - async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.write_skill_file( - name, - path, - content, - action_context=self._action_context(execution_context), - ) - - async def preview_skill_zip( - self, - context: TenantContext, - file_bytes: bytes, - filename: str, - source_subdir: str = '', - target_suffix: str = 'upload', - ) -> list[dict]: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.preview_skill_zip( - file_bytes, - filename, - source_subdir, - target_suffix, - action_context=self._action_context(execution_context), - ) - - async def install_skill_zip( - self, - context: TenantContext, - file_bytes: bytes, - filename: str, - source_paths: list[str] | None = None, - source_path: str = '', - source_subdir: str = '', - target_suffix: str = 'upload', - ) -> list[dict]: - execution_context = await self._validated_skill_execution_context(context) - return await self.client.install_skill_zip( - file_bytes, - filename, - source_paths, - source_path, - source_subdir, - target_suffix, - action_context=self._action_context(execution_context), - ) - def _serialize_result(self, result: BoxExecutionResult) -> dict: stdout, stdout_truncated = self._truncate(result.stdout) stderr, stderr_truncated = self._truncate(result.stderr) @@ -1784,14 +1633,6 @@ class BoxService: 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, @@ -2011,7 +1852,6 @@ class BoxService: # ── 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(), diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py index 3dd91c67b..95f66ef93 100644 --- a/src/langbot/pkg/box/workspace.py +++ b/src/langbot/pkg/box/workspace.py @@ -1,43 +1,28 @@ """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. +This module is the middle layer between raw Box runtime primitives and +application-specific consumers. 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 +Higher layers add their own semantics on top; BoxWorkspaceSession retains only +workspace, execution, and managed-process concepts. """ from __future__ import annotations import os -import textwrap from typing import Any -PYTHON_MANIFEST_FILES = ( - 'requirements.txt', - 'pyproject.toml', - 'setup.py', - 'setup.cfg', -) +from ..utils.python_workspace import list_python_manifest_files + _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: @@ -98,13 +83,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str 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)) @@ -117,163 +95,6 @@ def classify_python_workspace(host_path: str | None) -> str | None: 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', - state_path: str | None = None, -) -> str: - """Wrap a command with a reusable sandbox-local Python env bootstrap. - - ``mount_path`` is always the source tree used for manifest hashing and - installation. ``state_path`` may point at a separate writable directory - for read-only source mounts; when omitted, legacy mutable-workspace behavior - stores the environment beside the source. - """ - writable_state_path = state_path or mount_path - bootstrap = textwrap.dedent( - f""" - set -e - - _LB_VENV_DIR="{writable_state_path}/.venv" - _LB_META_DIR="{writable_state_path}/.langbot" - _LB_META_FILE="$_LB_META_DIR/python-env.json" - _LB_LOCK_DIR="$_LB_META_DIR/python-env.lock" - _LB_TMP_DIR="{writable_state_path}/.tmp" - _LB_PIP_CACHE_DIR="{writable_state_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}" - max_manifest_bytes = 10 * 1024 * 1024 - 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 - if os.path.getsize(path) > max_manifest_bytes: - raise RuntimeError( - f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}" - ) - manifest_files.append(rel) - with open(path, "rb") as handle: - digest.update(rel.encode("utf-8")) - digest.update(b"\\0") - while chunk := handle.read(1024 * 1024): - digest.update(chunk) - 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. diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py index d6d34afd5..7787f7ae1 100644 --- a/src/langbot/pkg/cloud/entitlements.py +++ b/src/langbot/pkg/cloud/entitlements.py @@ -55,6 +55,10 @@ class EntitlementSnapshot(pydantic.BaseModel): # must continue to use generic ``features`` and ``limits`` exclusively. plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128) + subscription_started_at: int | None = pydantic.Field(default=None, ge=0) + plan_revision: int | None = pydantic.Field(default=None, ge=1) + monthly_bonus_credits: int | None = pydantic.Field(default=None, ge=0) + @pydantic.field_validator('features') @classmethod def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]: diff --git a/src/langbot/pkg/cloud/plan_policy.py b/src/langbot/pkg/cloud/plan_policy.py new file mode 100644 index 000000000..dc64b32f5 --- /dev/null +++ b/src/langbot/pkg/cloud/plan_policy.py @@ -0,0 +1,42 @@ +"""Workspace plan policies evaluated from trusted entitlement projections.""" + +from .entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError +from ..platform.adapter_names import canonical_adapter_name + + +def platform_feature_names(adapter: str) -> set[str]: + canonical = canonical_adapter_name(adapter) + names = {adapter, canonical} + if adapter.lower() == 'line': + names.update({'line', 'LINE'}) + if canonical.endswith('-omni'): + names.add(canonical[:-5]) + names.add(canonical[:-5] + '-eba') + return {'platform.' + name for name in names} + + +async def workspace_entitlement(ap, workspace_uuid: str): + if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud': + return None + resolver = getattr(ap, 'entitlement_resolver', None) + if resolver is None: + raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable') + return await resolver.resolve(workspace_uuid) + + +async def require_workspace_platform(ap, workspace_uuid: str, adapter: str) -> None: + snapshot = await workspace_entitlement(ap, workspace_uuid) + if snapshot is None: + return + for feature in platform_feature_names(adapter): + if snapshot.features.get(feature) is False: + raise EntitlementFeatureUnavailableError(feature, entitlement_revision=snapshot.entitlement_revision) + + +async def workspace_history_retention_days(ap, workspace_uuid: str, fallback: int = 30) -> int: + snapshot = await workspace_entitlement(ap, workspace_uuid) + if snapshot is None: + return fallback + # Old signed snapshots inherit the existing 30-day policy. Never interpret + # missing entitlement data as an unlimited retention policy. + return max(1, snapshot.limits.get('chat_history.retention_days', 30)) diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 7da4a0a15..4b4d499ab 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -47,6 +47,7 @@ from . import entities as core_entities from ..rag.knowledge import kbmgr as rag_mgr from ..rag.service import RAGRuntimeService from ..vector import mgr as vectordb_mgr +from ..skill import repository as skill_repository from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module from ..skill import manager as skill_mgr @@ -91,6 +92,7 @@ class Application: # TODO move to pipeline tool_mgr: llm_tool_mgr.ToolManager = None box_service: box_service_module.BoxService = None + skill_repository: skill_repository.SkillRepository = None # ======= Config manager ======= @@ -309,6 +311,14 @@ class Application: if runtime_ops_reporter is not None: runtime_ops_stats = runtime_ops_reporter.get_stats() + telemetry_stats = {} + telemetry_health = getattr(self.telemetry, 'health', None) + if callable(telemetry_health): + try: + telemetry_stats = telemetry_health() + except Exception: + telemetry_stats = {} + return { 'asyncio_tasks': asyncio_tasks, 'event_loop': self.event_loop_monitor.snapshot(), @@ -320,7 +330,7 @@ class Application: 'models': model_stats, 'runtimes': runtime_stats, 'runtime_ops': runtime_ops_stats, - 'telemetry_tasks': len(getattr(self.telemetry, 'send_tasks', ())), + 'telemetry': telemetry_stats, } async def initialize(self): @@ -350,6 +360,13 @@ class Application: async def run(self): self.event_loop_monitor.start() + from ..utils.execution_lease import maintain_execution_leases + + self.task_mgr.create_task( + maintain_execution_leases(self), + name='execution-lease-maintenance', + scopes=[core_entities.LifecycleControlScope.APPLICATION], + ) try: if ( self.directory_projection_service is not None @@ -412,7 +429,10 @@ class Application: monitoring_cfg = self.instance_config.data.get('monitoring', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) - monitoring_enabled = auto_cleanup_cfg.get('enabled', True) + monitoring_enabled = ( + auto_cleanup_cfg.get('enabled', True) + or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud' + ) retention_days = self._get_positive_int_config( auto_cleanup_cfg.get('retention_days', 30), default=30, @@ -525,7 +545,7 @@ class Application: if total_deleted > 0: self.logger.info( f'Storage maintenance for Workspace {context.workspace_uuid}: ' - f'deleted expired files: {deleted}' + f'cleaned expired storage data: {deleted}' ) except asyncio.CancelledError: raise diff --git a/src/langbot/pkg/core/boot.py b/src/langbot/pkg/core/boot.py index 1b15e9922..6eead3f65 100644 --- a/src/langbot/pkg/core/boot.py +++ b/src/langbot/pkg/core/boot.py @@ -62,7 +62,6 @@ async def main(loop: asyncio.AbstractEventLoop): import signal def signal_handler(sig, frame): - print('[Signal] Program exit.') runtime_loop.call_soon_threadsafe(shutdown_requested.set) signal.signal(signal.SIGINT, signal_handler) diff --git a/src/langbot/pkg/core/bootutils/files.py b/src/langbot/pkg/core/bootutils/files.py index 067b60c10..660f945bf 100644 --- a/src/langbot/pkg/core/bootutils/files.py +++ b/src/langbot/pkg/core/bootutils/files.py @@ -18,13 +18,11 @@ required_paths = [ async def generate_files() -> list[str]: - global required_files, required_paths - from ...utils import paths as path_utils - for required_paths in required_paths: - if not os.path.exists(required_paths): - os.mkdir(required_paths) + for path in required_paths: + if not os.path.exists(path): + os.mkdir(path) generated_files = [] for file in required_files: diff --git a/src/langbot/pkg/core/bootutils/log.py b/src/langbot/pkg/core/bootutils/log.py index 7b78c93a2..3bf1bb5f6 100644 --- a/src/langbot/pkg/core/bootutils/log.py +++ b/src/langbot/pkg/core/bootutils/log.py @@ -1,7 +1,9 @@ import logging import logging.handlers import os +import queue import sys +import threading import time import colorlog @@ -23,6 +25,122 @@ LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max) LOG_DIR = 'data/logs' +CONSOLE_QUEUE_CAPACITY = 256 +CONSOLE_RECORD_MAX_BYTES = 16 * 1024 +CONSOLE_CLOSE_TIMEOUT = 0.2 + + +class NonBlockingConsoleHandler(logging.Handler): + """Isolate a stalled console from callers with at most 4 MiB of queued bytes. + + Only formatted UTF-8 bytes cross the thread boundary, so queued records do + not retain arguments or exception frames. Overflow drops console copies; + other handlers still receive the original records. + """ + + def __init__(self, stream, *, capacity: int = CONSOLE_QUEUE_CAPACITY): + if capacity <= 0: + raise ValueError('Console queue capacity must be positive') + # Drain startup prints before the worker can fill the pipe. Otherwise + # interpreter finalization could block flushing the original stream. + stream.flush() + super().__init__() + self._queue: queue.Queue[bytes | None] = queue.Queue(maxsize=capacity) + # logging.shutdown holds Handler.lock while joining the worker. + self._state_lock = threading.Lock() + self._failed = threading.Event() + self.dropped_records = 0 + self.write_error: str | None = None + # The worker owns this descriptor. Do not share a buffered Python stream + # lock with the main thread or interpreter finalization. + self._fd = os.dup(stream.fileno()) + self._worker = threading.Thread(target=self._consume, name='langbot-console-log', daemon=True) + try: + self._worker.start() + except Exception: + os.close(self._fd) + super().close() + raise + + def emit(self, record: logging.LogRecord) -> None: + with self._state_lock: + if self._closed or self._failed.is_set() or self._queue.full(): + self.dropped_records += 1 + return + try: + text = self.format(record) + payload = (text[:CONSOLE_RECORD_MAX_BYTES] + '\n').encode('utf-8', errors='backslashreplace') + if len(text) > CONSOLE_RECORD_MAX_BYTES or len(payload) > CONSOLE_RECORD_MAX_BYTES: + marker = b'\n[console log truncated]\n' + payload = ( + payload[: CONSOLE_RECORD_MAX_BYTES - len(marker)] + .decode('utf-8', errors='ignore') + .encode('utf-8') + + marker + ) + self._queue.put_nowait(payload) + except queue.Full: + self.dropped_records += 1 + except Exception: + # Logging's default handleError writes to stderr, which may + # share the same stalled container logging driver. + self.dropped_records += 1 + + def _write(self, payload: bytes) -> None: + remaining = memoryview(payload) + while remaining: + written = os.write(self._fd, remaining) + if written == 0: + raise OSError('Console write made no progress') + remaining = remaining[written:] + + def _consume(self) -> None: + reported_drops = 0 + try: + while (payload := self._queue.get()) is not None: + self._write(payload) + with self._state_lock: + dropped = self.dropped_records + if dropped > reported_drops: + self._write( + f'[WARNING] Console logging dropped {dropped - reported_drops} records.\n'.encode('utf-8') + ) + reported_drops = dropped + except OSError as exc: + with self._state_lock: + self.write_error = str(exc) + self._failed.set() + self.dropped_records += 1 + finally: + os.close(self._fd) + with self._state_lock: + while True: + try: + if self._queue.get_nowait() is not None: + self.dropped_records += 1 + except queue.Empty: + break + + def close(self) -> None: + with self._state_lock: + if self._closed: + return + super().close() + # A full queue must not make shutdown wait for the writer. + while True: + try: + self._queue.put_nowait(None) + break + except queue.Full: + try: + self._queue.get_nowait() + self.dropped_records += 1 + except queue.Empty: + # The writer consumed the last record after Full; + # retry inserting the shutdown sentinel. + continue + self._worker.join(timeout=CONSOLE_CLOSE_TIMEOUT) + class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler): """File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``. @@ -94,6 +212,10 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging. qcg_logger = logging.getLogger('langbot') + for handler in qcg_logger.handlers[:]: + qcg_logger.removeHandler(handler) + handler.close() + qcg_logger.setLevel(level) color_formatter = colorlog.ColoredFormatter( @@ -102,10 +224,7 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging. log_colors=log_colors_config, ) - stream_handler = logging.StreamHandler(sys.stdout) - # stream_handler.setLevel(level) - # stream_handler.setFormatter(color_formatter) - stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1) + stream_handler = NonBlockingConsoleHandler(sys.stdout) # 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. diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 77d8788df..dd2e060d5 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -28,6 +28,7 @@ from ...api.http.service import knowledge as knowledge_service 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 ...skill import repository as skill_repository from ...api.http.service import monitoring as monitoring_service from ...operation_trace import service as operation_trace_service from ...api.http.service import skill as skill_service @@ -137,6 +138,7 @@ class BuildAppStage(stage.BootingStage): webhook_service_inst = webhook_service.WebhookService(ap) ap.webhook_service = webhook_service_inst + ap.skill_repository = skill_repository.SkillRepository(ap) skill_service_inst = skill_service.SkillService(ap) ap.skill_service = skill_service_inst diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index bf76c3961..b41e2accd 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -59,6 +59,9 @@ _RUNTIME_POLICY_DEFAULTS = { 'restart_failure_window_seconds': 30.0, 'restart_circuit_open_seconds': 60.0, 'require_hard_limits': False, + # Cloud-only optional overrides; null inherits the unified policy. + 'dedicated': dict.fromkeys(('max_cpus', 'max_memory_mb', 'max_pids', 'max_open_files', 'max_file_size_mb')), + 'shared': dict.fromkeys(('max_cpus', 'max_memory_mb', 'max_pids', 'max_open_files', 'max_file_size_mb')), }, }, 'mcp': {'stdio': {'enabled': True}}, diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py index 6c5df7b81..b3ae3d1c7 100644 --- a/src/langbot/pkg/core/taskmgr.py +++ b/src/langbot/pkg/core/taskmgr.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ..i18n import t + import asyncio import json import typing @@ -202,7 +204,7 @@ class TaskWrapper: """Return the stable task projection exposed to API-key callers.""" if self.task.cancelled(): status = 'cancelled' - error = {'type': 'task_cancelled', 'message': 'Task was cancelled'} + error = {'type': 'task_cancelled', 'message': t('Task was cancelled')} result = None elif not self.task.done(): status = 'running' @@ -212,7 +214,7 @@ class TaskWrapper: exception = self.assume_exception() if exception is not None: status = 'failed' - error = {'type': 'task_failed', 'message': 'Task execution failed'} + error = {'type': 'task_failed', 'message': t('Task execution failed')} result = None else: status = 'succeeded' diff --git a/src/langbot/pkg/entity/persistence/agent_run.py b/src/langbot/pkg/entity/persistence/agent_run.py index 7d309110d..05d5c2b00 100644 --- a/src/langbot/pkg/entity/persistence/agent_run.py +++ b/src/langbot/pkg/entity/persistence/agent_run.py @@ -6,6 +6,7 @@ import datetime import sqlalchemy +from ...persistence.media import MediaReferenceText from .base import Base @@ -44,6 +45,10 @@ class AgentRun(Base): bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) """Bot UUID this run belongs to.""" + execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True) + execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """Host process lease, independent of execution duration and remote queue claims.""" + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True) """Run lifecycle status.""" @@ -103,15 +108,17 @@ class AgentRun(Base): authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) """Run-scoped authorization snapshot JSON.""" - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Additional metadata JSON.""" __table_args__ = ( + sqlalchemy.Index('ix_agent_run_media_ttl', 'workspace_id', 'created_at', 'id'), sqlalchemy.Index( 'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status' ), sqlalchemy.Index('ix_agent_run_runner_status', 'runner_id', 'status'), sqlalchemy.Index('ix_agent_run_queue_claim', 'queue_name', 'status', 'priority', 'id'), + sqlalchemy.Index('ix_agent_run_workspace_status_lease', 'workspace_id', 'status', 'execution_lease_expires_at'), ) @@ -179,7 +186,7 @@ class AgentRunEvent(Base): type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True) """Result event type.""" - data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + data_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Result event payload JSON.""" usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) @@ -191,10 +198,11 @@ class AgentRunEvent(Base): source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) """Source that appended the event.""" - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Additional metadata JSON.""" __table_args__ = ( sqlalchemy.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'), sqlalchemy.Index('ix_agent_run_event_run_sequence', 'run_id', 'sequence'), + sqlalchemy.Index('ix_agent_run_event_media_ttl', 'created_at', 'id', 'run_id'), ) diff --git a/src/langbot/pkg/entity/persistence/event_log.py b/src/langbot/pkg/entity/persistence/event_log.py index bd730b03c..8132d7ce9 100644 --- a/src/langbot/pkg/entity/persistence/event_log.py +++ b/src/langbot/pkg/entity/persistence/event_log.py @@ -6,6 +6,7 @@ import sqlalchemy import datetime from .base import Base +from ...persistence.media import MediaReferenceText class EventLog(Base): @@ -66,7 +67,7 @@ class EventLog(Base): input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True) """Brief summary of input (truncated text, max 1000 chars).""" - input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + input_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Full input JSON if reasonably sized (AgentInput as JSON string).""" # Raw event reference @@ -82,5 +83,7 @@ class EventLog(Base): created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) """When this record was created.""" - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Additional metadata as JSON string.""" + + __table_args__ = (sqlalchemy.Index('ix_event_log_media_ttl', 'workspace_id', 'created_at', 'id'),) diff --git a/src/langbot/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py index ac22cd2e9..b60fd9dd4 100644 --- a/src/langbot/pkg/entity/persistence/metadata.py +++ b/src/langbot/pkg/entity/persistence/metadata.py @@ -9,7 +9,7 @@ class Metadata(Base): __tablename__ = 'metadata' key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) - value = sqlalchemy.Column(sqlalchemy.String(255)) + value = sqlalchemy.Column(sqlalchemy.Text()) class WorkspaceMetadata(Base): @@ -23,4 +23,4 @@ class WorkspaceMetadata(Base): primary_key=True, ) key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) - value = sqlalchemy.Column(sqlalchemy.String(255)) + value = sqlalchemy.Column(sqlalchemy.Text()) diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py index 1d2e8ed83..358c1791e 100644 --- a/src/langbot/pkg/entity/persistence/monitoring.py +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -1,5 +1,6 @@ import sqlalchemy +from ...persistence.media import MediaReferenceText from .base import Base @@ -19,20 +20,37 @@ class MonitoringMessage(Base): 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) - message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False) + message_content = sqlalchemy.Column(MediaReferenceText, nullable=False) session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True) + execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True) + execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True) + """Host process lease, independent of execution duration and remote queue claims.""" + status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query - variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string + variables = sqlalchemy.Column(MediaReferenceText, nullable=True) # Query variables as JSON string role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant + event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) + parent_message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) __table_args__ = ( sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'), + sqlalchemy.Index('ix_monitoring_messages_media_ttl', 'workspace_uuid', 'timestamp', 'id'), sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'), + sqlalchemy.Index( + 'ix_monitoring_messages_conversation_page', 'workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id' + ), + sqlalchemy.Index( + 'ix_monitoring_messages_workspace_status_lease', + 'workspace_uuid', + 'status', + 'execution_lease_expires_at', + ), ) @@ -63,6 +81,8 @@ class MonitoringLLMCall(Base): error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True) message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID + details = sqlalchemy.Column(sqlalchemy.JSON, nullable=True) + __table_args__ = ( sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'), sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'), diff --git a/src/langbot/pkg/entity/persistence/transcript.py b/src/langbot/pkg/entity/persistence/transcript.py index f975ab245..d61be2e3b 100644 --- a/src/langbot/pkg/entity/persistence/transcript.py +++ b/src/langbot/pkg/entity/persistence/transcript.py @@ -6,6 +6,7 @@ import sqlalchemy import datetime from .base import Base +from ...persistence.media import MediaReferenceText class Transcript(Base): @@ -48,11 +49,11 @@ class Transcript(Base): content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) """Text content summary (may be truncated for large messages, max 4000 chars).""" - content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + content_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Full structured content as JSON string (Message model dump).""" # Attachment references - attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + attachment_refs_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Attachment references as JSON string.""" # Sequence for cursor-based pagination @@ -69,11 +70,12 @@ class Transcript(Base): created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow) """When this item was created.""" - metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True) + metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True) """Additional metadata as JSON string (sender_id, platform, etc.).""" # Indexes __table_args__ = ( + sqlalchemy.Index('ix_transcript_media_ttl', 'workspace_id', 'created_at', 'id'), sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'), sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'), sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'), diff --git a/src/langbot/pkg/i18n/__init__.py b/src/langbot/pkg/i18n/__init__.py new file mode 100644 index 000000000..113b4f909 --- /dev/null +++ b/src/langbot/pkg/i18n/__init__.py @@ -0,0 +1,197 @@ +"""Small, dependency-free catalog for LangBot-owned runtime messages. + +English message templates are the stable catalog IDs. Diagnostic text from +providers, plugins and validators is deliberately not translated. +""" + +from __future__ import annotations + +import contextlib +import contextvars +import json +from pathlib import Path + +DEFAULT_LANGUAGE = 'en_US' +SUPPORTED_LANGUAGES = ('en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN') +language = contextvars.ContextVar('langbot_message_language', default=DEFAULT_LANGUAGE) +_catalogs = { + locale: json.loads((Path(__file__).parent / 'locales' / f'{locale}.json').read_text(encoding='utf-8')) + for locale in SUPPORTED_LANGUAGES +} + +# Normalize existing API wording at the response boundary. Only exact owned +# messages are matched; validators and third-party diagnostics pass through. +_ALIASES = { + **{ + name: 'Resource not found' + for name in ( + 'bot not found', + 'model not found', + 'provider not found', + 'plugin not found', + 'Server not found', + 'Skill not found', + 'Pipeline not found', + 'pipeline not found', + 'knowledge base not found', + 'Knowledge base not found', + 'User not found', + 'Account not found', + 'agent not found', + 'API key not found', + 'Message not found', + 'Task not found', + 'Passkey not found', + 'Webhook not found', + 'Bot not found or not available', + 'Bot is unavailable', + 'WebSocket adapter not found', + 'adapter not found', + 'requester not found', + 'icon not found', + 'Session not found', + ) + }, + 'Unauthorized': 'Invalid authentication credentials', + 'No valid user token provided': 'Invalid authentication credentials', + 'No valid API key provided': 'Invalid authentication credentials', + 'Invalid username or password': 'Invalid authentication credentials', + 'Invalid TOTP code': 'Invalid TOTP or recovery code', + 'Too many attempts, try again later': 'Too many requests. Please try again later.', + 'The configured Agent runner is unavailable': 'Agent runner temporarily unavailable. Please try again.', + 'The configured Agent runner is not authorized': 'Agent runner not authorized for this pipeline.', + 'The Agent runner could not complete this test': 'Agent runner execution failed.', + 'Forbidden': 'Permission denied', + 'Member removal permission is required': 'Permission denied', + 'The Agent runner returned an invalid response': 'The Agent runner returned an invalid response.', + 'Invalid or expired state': 'Authorization failed. Please try signing in again.', + 'Space OAuth Workspace binding mismatch': 'Authorization failed. Please try signing in again.', + 'Space OAuth response is missing the Cloud Workspace binding': 'Authorization failed. Please try signing in again.', + 'Space launch requires Cloud mode': 'This feature is unavailable in this Workspace.', + 'An Account is required': 'Please sign in with your LangBot Account', + 'ChatGPT authorization requires an authorized workspace user': 'Permission denied', + 'ChatGPT authorization is busy. Please retry shortly.': 'Server is busy. Please try again later.', + 'ChatGPT authorization network error. Please retry.': 'Connection failed', + 'ChatGPT credential storage failed. Please retry.': 'Failed to save ChatGPT credentials. Please retry.', + 'ChatGPT Codex response was not completed': 'ChatGPT Codex response failed or was incomplete. Please retry.', + 'ChatGPT Codex stream ended before completion. Please retry.': 'ChatGPT Codex response failed or was incomplete. Please retry.', + 'Not permitted': 'Permission denied', + 'Unauthorized or session expired': 'Invalid authentication credentials', + 'TOTP is not enabled for that Account': 'TOTP is not enabled for this Account', + 'No pending TOTP enrolment for this Account': 'TOTP is not enabled for this Account', + 'Too many active credential exchanges': 'Server is busy. Please try again later.', + 'Timeout waiting for QR code': 'Request timed out', + 'Model availability is unavailable': 'This feature is unavailable in this Workspace.', + 'Survey not available': 'This feature is unavailable in this Workspace.', + **{ + message: 'Submission failed. Please try again.' + for message in ( + 'Failed to submit response', + 'Failed to submit feedback', + 'Failed to dismiss', + ) + }, + **{ + message: 'File size exceeds the limit.' + for message in ( + 'Image size exceeds 10MB limit.', + 'file size exceeds 10MB limit', + 'attachment too large', + ) + }, + **{ + message: 'Authorization failed. Please try signing in again.' + for message in ( + 'Space launch failed', + 'Space OAuth failed', + 'LangBot Account binding failed', + 'Failed to get access token from Space', + 'ChatGPT returned an incomplete authorization. Please sign in again.', + 'ChatGPT authorization was cancelled or replaced. Please retry.', + 'ChatGPT returned an invalid authorization response', + 'ChatGPT returned an invalid device code', + 'ChatGPT returned an invalid device code expiry', + 'ChatGPT device authorization failed. Please start again.', + 'ChatGPT returned an incomplete device authorization', + 'ChatGPT token exchange failed. Please start sign-in again.', + ) + }, +} + + +def normalize_language(value: str | None) -> str: + value = (value or '').replace('-', '_') + aliases = { + 'zh_CN': 'zh_Hans', + 'zh_TW': 'zh_Hant', + 'en': 'en_US', + 'ja': 'ja_JP', + 'es': 'es_ES', + 'ru': 'ru_RU', + 'th': 'th_TH', + 'vi': 'vi_VN', + } + value = aliases.get(value, value) + return value if value in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE + + +@contextlib.contextmanager +def language_scope(locale: str): + token = language.set(normalize_language(locale)) + try: + yield + finally: + language.reset(token) + + +def t(message: str, *, locale: str | None = None, **params) -> str: + selected = normalize_language(locale or language.get()) + # Preserve established English diagnostic wording and resource names. + # Aliases only share translations; they do not rewrite the source language. + key = _ALIASES.get(message, message) + text = message if selected == DEFAULT_LANGUAGE else _catalogs[selected].get(key, message) + return text.format(**params) if params else text + + +def error_text(code: int | str, message: str) -> str: + """Stable business codes take precedence over variable diagnostic wording.""" + if language.get() == DEFAULT_LANGUAGE: + return message + messages = { + 'permission_denied': 'Permission denied', + 'resource_not_found': 'Resource not found', + 'membership_not_found': 'Resource not found', + 'invalid_authentication': 'Invalid authentication credentials', + 'task_capacity_exceeded': 'Server is busy. Please try again later.', + 'blocking_work_capacity_exceeded': 'Server is busy. Please try again later.', + 'edition_limit': 'This feature is unavailable in this Workspace.', + 'entitlement_unavailable': 'This feature is unavailable in this Workspace.', + 'last_owner_required': 'The Workspace must retain an owner.', + 'already_a_member': 'This user is already a member.', + 'invitation_email_mismatch': 'The invitation email does not match your Account', + 'totp_invalid_code': 'Invalid TOTP or recovery code', + 'totp_challenge_invalid': 'Invalid TOTP or recovery code', + 'totp_already_enrolled': 'TOTP is already enabled for this Account', + 'totp_not_enrolled': 'TOTP is not enabled for this Account', + } + if code in ('invitation_invalid', 'invitation_expired', 'invitation_revoked', 'invitation_used'): + return t('Invalid or expired invitation') + return t(messages.get(code, message)) + + +def pipeline_notice(query, name: str, default: str, **params) -> str: + """Empty overrides inherit the Workspace language; user text stays literal. + + Only documented placeholders are substituted, so arbitrary braces in custom + messages cannot cause a formatting exception. + """ + config = query.pipeline_config.get('output', {}).get('misc', {}) + custom = config.get(name) + # This was the shipped default before language inheritance existed. + if name == 'failure-hint' and custom == 'Request failed.': + custom = None + if not isinstance(custom, str) or not custom.strip(): + return t(default, **params) + for key, value in params.items(): + custom = custom.replace('{' + key + '}', str(value)) + return custom diff --git a/src/langbot/pkg/i18n/adapters.py b/src/langbot/pkg/i18n/adapters.py new file mode 100644 index 000000000..899b57a19 --- /dev/null +++ b/src/langbot/pkg/i18n/adapters.py @@ -0,0 +1,31 @@ +"""Language boundaries for adapter-owned replies outside the Pipeline.""" + +import functools + +from . import DEFAULT_LANGUAGE, language_scope, t +from .workspace import _cache, get_workspace_language + + +def _owner(adapter): + logger = getattr(adapter, 'logger', None) + context = getattr(logger, 'execution_context', None) + workspace_uuid = getattr(context, 'workspace_uuid', None) + return getattr(logger, 'ap', None), workspace_uuid if isinstance(workspace_uuid, str) else None + + +def adapter_t(adapter, message: str, **params) -> str: + """Synchronous SDK callbacks use the bot's own cached language, never a peer's.""" + ap, workspace_uuid = _owner(adapter) + cached = _cache(ap).get(workspace_uuid) if ap is not None and workspace_uuid else None + return t(message, locale=cached[0] if cached else DEFAULT_LANGUAGE, **params) + + +def localized_adapter(method): + @functools.wraps(method) + async def wrapped(self, *args, **kwargs): + ap, workspace_uuid = _owner(self) + locale = await get_workspace_language(ap, workspace_uuid) if ap is not None else DEFAULT_LANGUAGE + with language_scope(locale): + return await method(self, *args, **kwargs) + + return wrapped diff --git a/src/langbot/pkg/i18n/locales/en_US.json b/src/langbot/pkg/i18n/locales/en_US.json new file mode 100644 index 000000000..dd07ff255 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/en_US.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Request failed.", + "Too many requests. Please try again later.": "Too many requests. Please try again later.", + "Your message contains inappropriate content. Please revise it.": "Your message contains inappropriate content. Please revise it.", + "Content checking failed. Please contact the administrator.": "Content checking failed. Please contact the administrator.", + "Calling {tools}...": "Calling {tools}...", + "Agent runner not found: {runner}": "Agent runner not found: {runner}", + "Agent runner not authorized for this pipeline.": "Agent runner not authorized for this pipeline.", + "Agent runner temporarily unavailable. Please try again.": "Agent runner temporarily unavailable. Please try again.", + "Agent runner execution failed.": "Agent runner execution failed.", + "The Agent runner returned an invalid response.": "The Agent runner returned an invalid response.", + "Model request failed": "Model request failed", + "Context length exceeded": "Context length exceeded", + "Invalid request parameters": "Invalid request parameters", + "Invalid API key": "Invalid API key", + "Invalid model or endpoint": "Invalid model or endpoint", + "Rate limit exceeded or insufficient balance": "Rate limit exceeded or insufficient balance", + "Request timed out": "Request timed out", + "Connection failed": "Connection failed", + "API error": "API error", + "Unknown error": "Unknown error", + "Internal server error": "Internal server error", + "Resource not found": "Resource not found", + "Permission denied": "Permission denied", + "Invalid authentication credentials": "Invalid authentication credentials", + "MCP runtime failed": "MCP runtime failed", + "Task execution failed": "Task execution failed", + "Task was cancelled": "Task was cancelled", + "Maximum number of {resource} ({limit}) reached": "Maximum number of {resource} ({limit}) reached", + "extensions": "extensions", + "knowledge bases": "knowledge bases", + "MCP server already exists: {name}": "MCP server already exists: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models is managed by Cloud and cannot be modified", + "Please sign in with your LangBot Account": "Please sign in with your LangBot Account", + "Current password is incorrect": "Current password is incorrect", + "Invalid TOTP or recovery code": "Invalid TOTP or recovery code", + "No local password set, please set a password first": "No local password set, please set a password first", + "This LangBot Account is already bound to another user": "This LangBot Account is already bound to another user", + "Invalid recovery key": "Invalid recovery key", + "Done": "Done", + "Thank you for your feedback": "Thank you for your feedback", + "Failed to process feedback": "Failed to process feedback", + "Helpful": "Helpful", + "Not helpful": "Not helpful", + "Select an option": "Select an option", + "Enter a value": "Enter a value", + "Submit": "Submit", + "Submitted": "Submitted", + "Thinking": "Thinking", + "AI-generated": "AI-generated", + "AI-generated content. Refer to the cited sources for details.": "AI-generated content. Refer to the cited sources for details.", + "No bridges configured.": "No bridges configured.", + "Signing in to all bridges again...": "Signing in to all bridges again...", + "Bridge status:": "Bridge status:", + "Signed in ✓": "Signed in ✓", + "Not signed in ✗": "Not signed in ✗", + "None": "None", + "Bridge {bridge}: skipped (login command or DM room missing).": "Bridge {bridge}: skipped (login command or DM room missing).", + "Bridge {bridge}: sending {command}...": "Bridge {bridge}: sending {command}...", + "Bridge {bridge}: sign-out failed.": "Bridge {bridge}: sign-out failed.", + "Bridge {bridge}: sign-in started.": "Bridge {bridge}: sign-in started.", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack authorization completed. Return to LangBot to finish setup.", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack authorization failed or expired. Return to LangBot for details.", + "TOTP is already enabled for this Account": "TOTP is already enabled for this Account", + "TOTP is not enabled for this Account": "TOTP is not enabled for this Account", + "Invalid or expired passkey challenge": "Invalid or expired passkey challenge", + "Passkey credential not recognized": "Passkey credential not recognized", + "Account is disabled": "Account is disabled", + "Modifying login info is disabled": "Modifying login info is disabled", + "Invalid GitHub repository URL": "Invalid GitHub repository URL", + "Server is busy. Please try again later.": "Server is busy. Please try again later.", + "This feature is unavailable in this Workspace.": "This feature is unavailable in this Workspace.", + "The Workspace must retain an owner.": "The Workspace must retain an owner.", + "Invalid or expired invitation": "Invalid or expired invitation", + "This user is already a member.": "This user is already a member.", + "Voice connection failed": "Voice connection failed", + "User is not in the voice channel": "User is not in the voice channel", + "Voice service is unavailable": "Voice service is unavailable", + "Submission failed. Please try again.": "Submission failed. Please try again.", + "File size exceeds the limit.": "File size exceeds the limit.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "File size exceeds 10MB limit. Please split large files into smaller parts.", + "Authorization failed. Please try signing in again.": "Authorization failed. Please try signing in again.", + "Bind the LangBot Account with the same email as this local Account": "Bind the LangBot Account with the same email as this local Account", + "Only local accounts can bind to Space": "Only local accounts can bind to Space", + "System already initialized": "System already initialized", + "System not initialized": "System not initialized", + "Password login is disabled on LangBot Cloud": "Password login is disabled on LangBot Cloud", + "This edition supports one Workspace per instance": "This edition supports one Workspace per instance", + "Cloud Workspaces are created by the SaaS control plane": "Cloud Workspaces are created by the SaaS control plane", + "Login with your LangBot Account to accept this invitation": "Login with your LangBot Account to accept this invitation", + "RAG migration is not needed": "RAG migration is not needed", + "New Agent": "New Agent", + "New Pipeline": "New Pipeline", + "Provider {provider} does not support {model_type} models": "Provider {provider} does not support {model_type} models", + "Invalid JSON format": "Invalid JSON format", + "ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT sign-in required. Open this provider and sign in again.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT denied this request. Check subscription and workspace permissions.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rejected the model or request. Check the selected model and request settings.", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex upstream request failed. Please retry later.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT subscription usage limit reached. Please retry later or check your plan.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex response failed or was incomplete. Please retry.", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT token refresh temporarily failed. Please retry.", + "A second factor is required": "A second factor is required", + "Sign in or provide registration details to accept this invitation": "Sign in or provide registration details to accept this invitation", + "Sign out before creating the invited local Account": "Sign out before creating the invited local Account", + "Turnstile verification failed": "Turnstile verification failed", + "The invitation email does not match your Account": "The invitation email does not match your Account", + "Failed to save ChatGPT credentials. Please retry.": "Failed to save ChatGPT credentials. Please retry.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.", + "Option {number}": "Option {number}", + "Confirmation needed": "Confirmation needed", + "Session reset successfully": "Session reset successfully" +} diff --git a/src/langbot/pkg/i18n/locales/es_ES.json b/src/langbot/pkg/i18n/locales/es_ES.json new file mode 100644 index 000000000..116aa09a8 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/es_ES.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "La solicitud ha fallado.", + "Too many requests. Please try again later.": "Demasiadas solicitudes. Inténtalo más tarde.", + "Your message contains inappropriate content. Please revise it.": "Tu mensaje contiene contenido inapropiado. Modifícalo.", + "Content checking failed. Please contact the administrator.": "No se pudo verificar el contenido. Contacta con el administrador.", + "Calling {tools}...": "Llamando a {tools}…", + "Agent runner not found: {runner}": "No se encontró el ejecutor del agente: {runner}", + "Agent runner not authorized for this pipeline.": "Este flujo no tiene autorización para usar el ejecutor del agente.", + "Agent runner temporarily unavailable. Please try again.": "El ejecutor del agente no está disponible temporalmente. Reinténtalo.", + "Agent runner execution failed.": "La ejecución del agente ha fallado.", + "The Agent runner returned an invalid response.": "El ejecutor del agente devolvió una respuesta no válida.", + "Model request failed": "Error en la solicitud al modelo", + "Context length exceeded": "Se superó el límite de contexto", + "Invalid request parameters": "Parámetros de solicitud no válidos", + "Invalid API key": "Clave API no válida", + "Invalid model or endpoint": "Modelo o endpoint no válido", + "Rate limit exceeded or insufficient balance": "Límite de solicitudes excedido o saldo insuficiente", + "Request timed out": "La solicitud agotó el tiempo de espera", + "Connection failed": "Error de conexión", + "API error": "Error de API", + "Unknown error": "Error desconocido", + "Internal server error": "Error interno del servidor", + "Resource not found": "Recurso no encontrado", + "Permission denied": "No tienes permiso para realizar esta acción", + "Invalid authentication credentials": "Error de autenticación. Vuelve a iniciar sesión", + "MCP runtime failed": "Error de ejecución de MCP", + "Task execution failed": "La tarea ha fallado", + "Task was cancelled": "La tarea se ha cancelado", + "Maximum number of {resource} ({limit}) reached": "Se alcanzó el máximo de {resource} ({limit})", + "extensions": "extensiones", + "knowledge bases": "bases de conocimiento", + "MCP server already exists: {name}": "El servidor MCP ya existe: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models está gestionado por Cloud y no se puede modificar", + "Please sign in with your LangBot Account": "Inicia sesión con tu cuenta de LangBot", + "Current password is incorrect": "La contraseña actual es incorrecta", + "Invalid TOTP or recovery code": "Código TOTP o de recuperación no válido", + "No local password set, please set a password first": "No hay contraseña local. Configura una primero", + "This LangBot Account is already bound to another user": "Esta cuenta de LangBot ya está vinculada a otro usuario", + "Invalid recovery key": "Clave de recuperación no válida", + "Done": "Hecho", + "Thank you for your feedback": "Gracias por tus comentarios", + "Failed to process feedback": "No se pudieron procesar los comentarios", + "Helpful": "Útil", + "Not helpful": "No es útil", + "Select an option": "Selecciona una opción", + "Enter a value": "Introduce un valor", + "Submit": "Enviar", + "Submitted": "Enviado", + "Thinking": "Razonamiento", + "AI-generated": "Generado por IA", + "AI-generated content. Refer to the cited sources for details.": "Contenido generado por IA. Consulta las fuentes citadas para más detalles.", + "No bridges configured.": "No hay puentes configurados.", + "Signing in to all bridges again...": "Iniciando sesión de nuevo en todos los puentes…", + "Bridge status:": "Estado de los puentes:", + "Signed in ✓": "Sesión iniciada ✓", + "Not signed in ✗": "Sin sesión ✗", + "None": "Ninguno", + "Bridge {bridge}: skipped (login command or DM room missing).": "Puente {bridge}: omitido (falta comando de inicio de sesión o sala DM).", + "Bridge {bridge}: sending {command}...": "Puente {bridge}: enviando {command}…", + "Bridge {bridge}: sign-out failed.": "Puente {bridge}: no se pudo cerrar sesión.", + "Bridge {bridge}: sign-in started.": "Puente {bridge}: inicio de sesión en curso.", + "Slack authorization completed. Return to LangBot to finish setup.": "Autorización de Slack completada. Vuelve a LangBot para terminar la configuración.", + "Slack authorization failed or expired. Return to LangBot for details.": "La autorización de Slack falló o caducó. Consulta los detalles en LangBot.", + "TOTP is already enabled for this Account": "Esta cuenta ya tiene TOTP activado", + "TOTP is not enabled for this Account": "Esta cuenta no tiene TOTP activado", + "Invalid or expired passkey challenge": "El desafío de clave de acceso no es válido o ha caducado", + "Passkey credential not recognized": "No se reconoce la clave de acceso", + "Account is disabled": "La cuenta está desactivada", + "Modifying login info is disabled": "No se permite modificar los datos de inicio de sesión", + "Invalid GitHub repository URL": "URL del repositorio GitHub no válida", + "Server is busy. Please try again later.": "El servidor está ocupado. Inténtalo más tarde.", + "This feature is unavailable in this Workspace.": "Esta función no está disponible en este espacio de trabajo.", + "The Workspace must retain an owner.": "El espacio de trabajo debe conservar un propietario.", + "Invalid or expired invitation": "Invitación no válida o caducada", + "This user is already a member.": "Este usuario ya es miembro.", + "Voice connection failed": "Error de conexión de voz", + "User is not in the voice channel": "El usuario no está en el canal de voz", + "Voice service is unavailable": "El servicio de voz no está disponible", + "Submission failed. Please try again.": "El envío ha fallado. Reinténtalo.", + "File size exceeds the limit.": "El archivo supera el tamaño permitido.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "El archivo supera los 10 MB. Divídelo en partes más pequeñas.", + "Authorization failed. Please try signing in again.": "La autorización ha fallado. Vuelve a iniciar sesión.", + "Bind the LangBot Account with the same email as this local Account": "Vincula una cuenta de LangBot con el mismo correo que esta cuenta local", + "Only local accounts can bind to Space": "Solo las cuentas locales pueden vincularse a Space", + "System already initialized": "El sistema ya está inicializado", + "System not initialized": "El sistema no está inicializado", + "Password login is disabled on LangBot Cloud": "El acceso con contraseña está desactivado en LangBot Cloud", + "This edition supports one Workspace per instance": "Esta edición admite un espacio de trabajo por instancia", + "Cloud Workspaces are created by the SaaS control plane": "Los espacios de trabajo Cloud se crean desde el panel SaaS", + "Login with your LangBot Account to accept this invitation": "Inicia sesión con tu cuenta de LangBot para aceptar la invitación", + "RAG migration is not needed": "No es necesaria la migración de RAG", + "New Agent": "Nuevo agente", + "New Pipeline": "Nuevo flujo", + "Provider {provider} does not support {model_type} models": "El proveedor {provider} no admite modelos {model_type}", + "Invalid JSON format": "Formato JSON no válido", + "ChatGPT sign-in required. Open this provider and sign in again.": "Debes iniciar sesión en ChatGPT. Abre este proveedor y vuelve a iniciar sesión.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT limitó la solicitud por frecuencia o uso. Inténtalo más tarde o revisa tu plan.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT rechazó la solicitud. Revisa la suscripción y los permisos del espacio de trabajo.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rechazó el modelo o la solicitud. Revisa el modelo seleccionado y la configuración.", + "ChatGPT Codex upstream request failed. Please retry later.": "Falló la solicitud a ChatGPT Codex. Inténtalo más tarde.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Se alcanzó el límite de uso de tu suscripción de ChatGPT. Inténtalo más tarde o revisa tu plan.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "La respuesta de ChatGPT Codex falló o está incompleta. Inténtalo de nuevo.", + "ChatGPT token refresh temporarily failed. Please retry.": "No se pudo renovar el token de ChatGPT temporalmente. Inténtalo de nuevo.", + "A second factor is required": "Se requiere un segundo factor de autenticación", + "Sign in or provide registration details to accept this invitation": "Inicia sesión o proporciona los datos de registro para aceptar esta invitación", + "Sign out before creating the invited local Account": "Cierra sesión antes de crear la cuenta local invitada", + "Turnstile verification failed": "La verificación de Turnstile falló. Inténtalo de nuevo", + "The invitation email does not match your Account": "El correo de la invitación no coincide con tu cuenta", + "Failed to save ChatGPT credentials. Please retry.": "No se pudieron guardar las credenciales de ChatGPT. Inténtalo de nuevo.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "No se pudo iniciar el acceso por dispositivo. Activa el inicio de sesión con código de dispositivo en la configuración de ChatGPT.", + "Option {number}": "Opción {number}", + "Confirmation needed": "Se requiere confirmación", + "Session reset successfully": "Sesión restablecida" +} diff --git a/src/langbot/pkg/i18n/locales/ja_JP.json b/src/langbot/pkg/i18n/locales/ja_JP.json new file mode 100644 index 000000000..292bb1b56 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/ja_JP.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "リクエストに失敗しました。", + "Too many requests. Please try again later.": "リクエストが多すぎます。しばらくしてから再試行してください。", + "Your message contains inappropriate content. Please revise it.": "不適切な内容が含まれています。メッセージを修正してください。", + "Content checking failed. Please contact the administrator.": "コンテンツの確認に失敗しました。管理者にお問い合わせください。", + "Calling {tools}...": "{tools} を呼び出しています…", + "Agent runner not found: {runner}": "Agent ランナーが見つかりません:{runner}", + "Agent runner not authorized for this pipeline.": "このパイプラインには Agent ランナーの使用権限がありません。", + "Agent runner temporarily unavailable. Please try again.": "Agent ランナーは一時的に利用できません。再試行してください。", + "Agent runner execution failed.": "Agent の実行に失敗しました。", + "The Agent runner returned an invalid response.": "Agent ランナーが無効な応答を返しました。", + "Model request failed": "モデルへのリクエストに失敗しました", + "Context length exceeded": "コンテキスト長の上限を超えました", + "Invalid request parameters": "リクエストのパラメータが無効です", + "Invalid API key": "API キーが無効です", + "Invalid model or endpoint": "モデルまたはエンドポイントが無効です", + "Rate limit exceeded or insufficient balance": "リクエスト制限を超えたか、残高が不足しています", + "Request timed out": "リクエストがタイムアウトしました", + "Connection failed": "接続に失敗しました", + "API error": "API エラー", + "Unknown error": "不明なエラー", + "Internal server error": "サーバー内部エラー", + "Resource not found": "リソースが見つかりません", + "Permission denied": "この操作の権限がありません", + "Invalid authentication credentials": "認証に失敗しました。再度ログインしてください", + "MCP runtime failed": "MCP の実行に失敗しました", + "Task execution failed": "タスクの実行に失敗しました", + "Task was cancelled": "タスクがキャンセルされました", + "Maximum number of {resource} ({limit}) reached": "{resource}の上限数({limit})に達しました", + "extensions": "拡張機能", + "knowledge bases": "ナレッジベース", + "MCP server already exists: {name}": "MCP サーバーは既に存在します:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models はクラウドで管理されているため変更できません", + "Please sign in with your LangBot Account": "LangBot アカウントでログインしてください", + "Current password is incorrect": "現在のパスワードが正しくありません", + "Invalid TOTP or recovery code": "認証コードまたはリカバリーコードが無効です", + "No local password set, please set a password first": "ローカルパスワードが未設定です。先に設定してください", + "This LangBot Account is already bound to another user": "この LangBot アカウントは別のユーザーに連携されています", + "Invalid recovery key": "リカバリーキーが無効です", + "Done": "完了しました", + "Thank you for your feedback": "フィードバックありがとうございます", + "Failed to process feedback": "フィードバックの処理に失敗しました", + "Helpful": "役に立った", + "Not helpful": "役に立たなかった", + "Select an option": "選択してください", + "Enter a value": "入力してください", + "Submit": "送信", + "Submitted": "送信済み", + "Thinking": "思考過程", + "AI-generated": "AI による生成コンテンツ", + "AI-generated content. Refer to the cited sources for details.": "AI が生成した内容です。詳細は引用元を参照してください。", + "No bridges configured.": "ブリッジが設定されていません。", + "Signing in to all bridges again...": "すべてのブリッジに再ログインしています…", + "Bridge status:": "ブリッジの状態:", + "Signed in ✓": "ログイン済み ✓", + "Not signed in ✗": "未ログイン ✗", + "None": "なし", + "Bridge {bridge}: skipped (login command or DM room missing).": "ブリッジ {bridge}:スキップしました(ログインコマンドまたは DM ルームがありません)。", + "Bridge {bridge}: sending {command}...": "ブリッジ {bridge}:{command} を送信中…", + "Bridge {bridge}: sign-out failed.": "ブリッジ {bridge}:ログアウトに失敗しました。", + "Bridge {bridge}: sign-in started.": "ブリッジ {bridge}:ログインを開始しました。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack の認証が完了しました。LangBot に戻って設定を完了してください。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack の認証に失敗したか期限切れです。詳細は LangBot で確認してください。", + "TOTP is already enabled for this Account": "このアカウントでは二段階認証が有効です", + "TOTP is not enabled for this Account": "このアカウントでは二段階認証が無効です", + "Invalid or expired passkey challenge": "パスキーの認証要求が無効または期限切れです", + "Passkey credential not recognized": "このパスキーは認識できません", + "Account is disabled": "アカウントは無効です", + "Modifying login info is disabled": "ログイン情報の変更は無効です", + "Invalid GitHub repository URL": "GitHub リポジトリの URL が無効です", + "Server is busy. Please try again later.": "サーバーが混雑しています。後で再試行してください。", + "This feature is unavailable in this Workspace.": "このワークスペースではこの機能を利用できません。", + "The Workspace must retain an owner.": "ワークスペースにはオーナーが必要です。", + "Invalid or expired invitation": "招待が無効または期限切れです", + "This user is already a member.": "このユーザーは既にメンバーです。", + "Voice connection failed": "音声接続に失敗しました", + "User is not in the voice channel": "ユーザーは音声チャンネルに参加していません", + "Voice service is unavailable": "音声サービスを利用できません", + "Submission failed. Please try again.": "送信に失敗しました。再試行してください。", + "File size exceeds the limit.": "ファイルサイズが上限を超えています。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "ファイルが 10MB を超えています。分割してアップロードしてください。", + "Authorization failed. Please try signing in again.": "認証に失敗しました。再度ログインしてください。", + "Bind the LangBot Account with the same email as this local Account": "このローカルアカウントと同じメールアドレスの LangBot アカウントを連携してください", + "Only local accounts can bind to Space": "Space と連携できるのはローカルアカウントのみです", + "System already initialized": "システムは初期化済みです", + "System not initialized": "システムが初期化されていません", + "Password login is disabled on LangBot Cloud": "LangBot Cloud ではパスワードログインは無効です", + "This edition supports one Workspace per instance": "このエディションではインスタンスごとにワークスペースは1つです", + "Cloud Workspaces are created by the SaaS control plane": "クラウドのワークスペースは SaaS 管理画面で作成します", + "Login with your LangBot Account to accept this invitation": "招待を承諾するには LangBot アカウントでログインしてください", + "RAG migration is not needed": "RAG の移行は不要です", + "New Agent": "新しい Agent", + "New Pipeline": "新しいパイプライン", + "Provider {provider} does not support {model_type} models": "プロバイダー {provider} は {model_type} モデルに対応していません", + "Invalid JSON format": "JSON 形式が無効です", + "ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT へのログインが必要です。このプロバイダーを開いて再ログインしてください。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT のリクエストが制限されました(頻度または使用量)。後で再試行するか、プランを確認してください。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT がリクエストを拒否しました。サブスクリプションとワークスペースの権限を確認してください。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT がモデルまたはリクエストを拒否しました。選択したモデルとリクエスト設定を確認してください。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex の上流リクエストに失敗しました。後で再試行してください。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT のサブスクリプション使用量が上限に達しました。後で再試行するか、プランを確認してください。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex の応答に失敗したか、応答が不完全です。再試行してください。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT トークンの更新に一時的に失敗しました。再試行してください。", + "A second factor is required": "二要素認証が必要です", + "Sign in or provide registration details to accept this invitation": "招待を承諾するにはログインするか、登録情報を入力してください", + "Sign out before creating the invited local Account": "招待されたローカルアカウントを作成する前にログアウトしてください", + "Turnstile verification failed": "人間確認に失敗しました。再試行してください", + "The invitation email does not match your Account": "招待のメールアドレスがアカウントと一致しません", + "Failed to save ChatGPT credentials. Please retry.": "ChatGPT 認証情報の保存に失敗しました。再試行してください。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ChatGPT のデバイスログインを開始できません。ChatGPT の設定でデバイスコードログインを有効にしてください。", + "Option {number}": "選択肢 {number}", + "Confirmation needed": "確認が必要です", + "Session reset successfully": "セッションをリセットしました" +} diff --git a/src/langbot/pkg/i18n/locales/ru_RU.json b/src/langbot/pkg/i18n/locales/ru_RU.json new file mode 100644 index 000000000..ed9529018 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/ru_RU.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Запрос не выполнен.", + "Too many requests. Please try again later.": "Слишком много запросов. Повторите позже.", + "Your message contains inappropriate content. Please revise it.": "Сообщение содержит недопустимый контент. Измените его.", + "Content checking failed. Please contact the administrator.": "Ошибка проверки содержимого. Обратитесь к администратору.", + "Calling {tools}...": "Вызов {tools}…", + "Agent runner not found: {runner}": "Исполнитель агента не найден: {runner}", + "Agent runner not authorized for this pipeline.": "У конвейера нет разрешения на использование исполнителя агента.", + "Agent runner temporarily unavailable. Please try again.": "Исполнитель агента временно недоступен. Повторите попытку.", + "Agent runner execution failed.": "Ошибка выполнения агента.", + "The Agent runner returned an invalid response.": "Исполнитель агента вернул некорректный ответ.", + "Model request failed": "Ошибка запроса к модели", + "Context length exceeded": "Превышена длина контекста", + "Invalid request parameters": "Некорректные параметры запроса", + "Invalid API key": "Недействительный API-ключ", + "Invalid model or endpoint": "Некорректная модель или адрес API", + "Rate limit exceeded or insufficient balance": "Превышен лимит запросов или недостаточно средств", + "Request timed out": "Время ожидания запроса истекло", + "Connection failed": "Ошибка подключения", + "API error": "Ошибка API", + "Unknown error": "Неизвестная ошибка", + "Internal server error": "Внутренняя ошибка сервера", + "Resource not found": "Ресурс не найден", + "Permission denied": "Нет разрешения на это действие", + "Invalid authentication credentials": "Ошибка аутентификации. Войдите снова", + "MCP runtime failed": "Ошибка выполнения MCP", + "Task execution failed": "Ошибка выполнения задачи", + "Task was cancelled": "Задача отменена", + "Maximum number of {resource} ({limit}) reached": "Достигнуто максимальное число {resource} ({limit})", + "extensions": "расширений", + "knowledge bases": "баз знаний", + "MCP server already exists: {name}": "Сервер MCP уже существует: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models управляется облаком и не может быть изменён", + "Please sign in with your LangBot Account": "Войдите с учётной записью LangBot", + "Current password is incorrect": "Текущий пароль неверен", + "Invalid TOTP or recovery code": "Неверный код TOTP или восстановления", + "No local password set, please set a password first": "Локальный пароль не задан. Сначала задайте пароль", + "This LangBot Account is already bound to another user": "Эта учётная запись LangBot уже связана с другим пользователем", + "Invalid recovery key": "Недействительный ключ восстановления", + "Done": "Готово", + "Thank you for your feedback": "Спасибо за отзыв", + "Failed to process feedback": "Не удалось обработать отзыв", + "Helpful": "Полезно", + "Not helpful": "Не полезно", + "Select an option": "Выберите вариант", + "Enter a value": "Введите значение", + "Submit": "Отправить", + "Submitted": "Отправлено", + "Thinking": "Ход рассуждений", + "AI-generated": "Создано ИИ", + "AI-generated content. Refer to the cited sources for details.": "Контент создан ИИ. Подробности смотрите в указанных источниках.", + "No bridges configured.": "Мосты не настроены.", + "Signing in to all bridges again...": "Повторный вход во все мосты…", + "Bridge status:": "Состояние мостов:", + "Signed in ✓": "Вход выполнен ✓", + "Not signed in ✗": "Вход не выполнен ✗", + "None": "Нет", + "Bridge {bridge}: skipped (login command or DM room missing).": "Мост {bridge}: пропущен (нет команды входа или комнаты DM).", + "Bridge {bridge}: sending {command}...": "Мост {bridge}: отправка {command}…", + "Bridge {bridge}: sign-out failed.": "Мост {bridge}: ошибка выхода.", + "Bridge {bridge}: sign-in started.": "Мост {bridge}: вход начат.", + "Slack authorization completed. Return to LangBot to finish setup.": "Авторизация Slack завершена. Вернитесь в LangBot для завершения настройки.", + "Slack authorization failed or expired. Return to LangBot for details.": "Авторизация Slack не удалась или истекла. Подробности в LangBot.", + "TOTP is already enabled for this Account": "Для этой учётной записи TOTP уже включён", + "TOTP is not enabled for this Account": "Для этой учётной записи TOTP не включён", + "Invalid or expired passkey challenge": "Запрос проверки ключа доступа недействителен или истёк", + "Passkey credential not recognized": "Ключ доступа не распознан", + "Account is disabled": "Учётная запись отключена", + "Modifying login info is disabled": "Изменение данных входа отключено", + "Invalid GitHub repository URL": "Некорректный URL репозитория GitHub", + "Server is busy. Please try again later.": "Сервер занят. Повторите позже.", + "This feature is unavailable in this Workspace.": "Эта функция недоступна в данном рабочем пространстве.", + "The Workspace must retain an owner.": "У рабочего пространства должен остаться владелец.", + "Invalid or expired invitation": "Приглашение недействительно или истекло", + "This user is already a member.": "Этот пользователь уже является участником.", + "Voice connection failed": "Ошибка голосового соединения", + "User is not in the voice channel": "Пользователь не в голосовом канале", + "Voice service is unavailable": "Голосовая служба недоступна", + "Submission failed. Please try again.": "Не удалось отправить. Повторите попытку.", + "File size exceeds the limit.": "Размер файла превышает ограничение.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "Размер файла превышает 10 МБ. Разделите его на части.", + "Authorization failed. Please try signing in again.": "Авторизация не удалась. Войдите снова.", + "Bind the LangBot Account with the same email as this local Account": "Свяжите учётную запись LangBot с тем же адресом почты, что и локальная", + "Only local accounts can bind to Space": "К Space можно привязать только локальные учётные записи", + "System already initialized": "Система уже инициализирована", + "System not initialized": "Система не инициализирована", + "Password login is disabled on LangBot Cloud": "В LangBot Cloud вход по паролю отключён", + "This edition supports one Workspace per instance": "Эта редакция поддерживает одно рабочее пространство на экземпляр", + "Cloud Workspaces are created by the SaaS control plane": "Облачные рабочие пространства создаются в панели SaaS", + "Login with your LangBot Account to accept this invitation": "Войдите в LangBot, чтобы принять приглашение", + "RAG migration is not needed": "Миграция RAG не требуется", + "New Agent": "Новый агент", + "New Pipeline": "Новый конвейер", + "Provider {provider} does not support {model_type} models": "Провайдер {provider} не поддерживает модели {model_type}", + "Invalid JSON format": "Некорректный формат JSON", + "ChatGPT sign-in required. Open this provider and sign in again.": "Требуется вход в ChatGPT. Откройте этого провайдера и войдите снова.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Запрос ChatGPT ограничен по частоте или использованию. Повторите позже или проверьте тариф.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT отклонил запрос. Проверьте подписку и права рабочего пространства.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT отклонил модель или запрос. Проверьте выбранную модель и настройки запроса.", + "ChatGPT Codex upstream request failed. Please retry later.": "Ошибка запроса к ChatGPT Codex. Повторите позже.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Достигнут лимит использования подписки ChatGPT. Повторите позже или проверьте тариф.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "Ошибка или неполный ответ ChatGPT Codex. Повторите запрос.", + "ChatGPT token refresh temporarily failed. Please retry.": "Временная ошибка обновления токена ChatGPT. Повторите попытку.", + "A second factor is required": "Требуется второй фактор аутентификации", + "Sign in or provide registration details to accept this invitation": "Войдите или укажите данные регистрации, чтобы принять приглашение", + "Sign out before creating the invited local Account": "Выйдите перед созданием приглашённой локальной учётной записи", + "Turnstile verification failed": "Проверка Turnstile не пройдена. Повторите попытку", + "The invitation email does not match your Account": "Адрес приглашения не совпадает с вашей учётной записью", + "Failed to save ChatGPT credentials. Please retry.": "Не удалось сохранить учётные данные ChatGPT. Повторите попытку.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Не удалось начать вход с устройства. Включите вход по коду устройства в настройках ChatGPT.", + "Option {number}": "Вариант {number}", + "Confirmation needed": "Требуется подтверждение", + "Session reset successfully": "Сессия сброшена" +} diff --git a/src/langbot/pkg/i18n/locales/th_TH.json b/src/langbot/pkg/i18n/locales/th_TH.json new file mode 100644 index 000000000..ff40282cd --- /dev/null +++ b/src/langbot/pkg/i18n/locales/th_TH.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "คำขอล้มเหลว", + "Too many requests. Please try again later.": "มีคำขอมากเกินไป โปรดลองอีกครั้งภายหลัง", + "Your message contains inappropriate content. Please revise it.": "ข้อความมีเนื้อหาไม่เหมาะสม โปรดแก้ไข", + "Content checking failed. Please contact the administrator.": "ตรวจสอบเนื้อหาไม่สำเร็จ โปรดติดต่อผู้ดูแลระบบ", + "Calling {tools}...": "กำลังเรียกใช้ {tools}…", + "Agent runner not found: {runner}": "ไม่พบตัวรัน Agent: {runner}", + "Agent runner not authorized for this pipeline.": "ไปป์ไลน์นี้ไม่ได้รับอนุญาตให้ใช้ตัวรัน Agent", + "Agent runner temporarily unavailable. Please try again.": "ตัวรัน Agent ไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้ง", + "Agent runner execution failed.": "การทำงานของ Agent ล้มเหลว", + "The Agent runner returned an invalid response.": "ตัวรัน Agent ส่งการตอบกลับที่ไม่ถูกต้อง", + "Model request failed": "คำขอไปยังโมเดลล้มเหลว", + "Context length exceeded": "ความยาวบริบทเกินขีดจำกัด", + "Invalid request parameters": "พารามิเตอร์คำขอไม่ถูกต้อง", + "Invalid API key": "คีย์ API ไม่ถูกต้อง", + "Invalid model or endpoint": "โมเดลหรือจุดเชื่อมต่อไม่ถูกต้อง", + "Rate limit exceeded or insufficient balance": "เกินขีดจำกัดคำขอหรือยอดเงินไม่เพียงพอ", + "Request timed out": "คำขอหมดเวลา", + "Connection failed": "การเชื่อมต่อล้มเหลว", + "API error": "ข้อผิดพลาด API", + "Unknown error": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ", + "Internal server error": "ข้อผิดพลาดภายในเซิร์ฟเวอร์", + "Resource not found": "ไม่พบทรัพยากร", + "Permission denied": "ไม่มีสิทธิ์ดำเนินการนี้", + "Invalid authentication credentials": "ยืนยันตัวตนไม่สำเร็จ โปรดเข้าสู่ระบบอีกครั้ง", + "MCP runtime failed": "การทำงานของ MCP ล้มเหลว", + "Task execution failed": "การทำงานของงานล้มเหลว", + "Task was cancelled": "งานถูกยกเลิกแล้ว", + "Maximum number of {resource} ({limit}) reached": "ถึงจำนวนสูงสุดของ{resource} ({limit}) แล้ว", + "extensions": "ส่วนขยาย", + "knowledge bases": "ฐานความรู้", + "MCP server already exists: {name}": "มีเซิร์ฟเวอร์ MCP อยู่แล้ว: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models จัดการโดย Cloud และไม่สามารถแก้ไขได้", + "Please sign in with your LangBot Account": "โปรดเข้าสู่ระบบด้วยบัญชี LangBot", + "Current password is incorrect": "รหัสผ่านปัจจุบันไม่ถูกต้อง", + "Invalid TOTP or recovery code": "รหัส TOTP หรือรหัสกู้คืนไม่ถูกต้อง", + "No local password set, please set a password first": "ยังไม่ได้ตั้งรหัสผ่านในระบบ โปรดตั้งรหัสผ่านก่อน", + "This LangBot Account is already bound to another user": "บัญชี LangBot นี้เชื่อมโยงกับผู้ใช้อื่นแล้ว", + "Invalid recovery key": "คีย์กู้คืนไม่ถูกต้อง", + "Done": "เสร็จแล้ว", + "Thank you for your feedback": "ขอบคุณสำหรับความคิดเห็น", + "Failed to process feedback": "ประมวลผลความคิดเห็นไม่สำเร็จ", + "Helpful": "มีประโยชน์", + "Not helpful": "ไม่มีประโยชน์", + "Select an option": "โปรดเลือก", + "Enter a value": "โปรดป้อนค่า", + "Submit": "ส่ง", + "Submitted": "ส่งแล้ว", + "Thinking": "กระบวนการคิด", + "AI-generated": "สร้างโดย AI", + "AI-generated content. Refer to the cited sources for details.": "เนื้อหาสร้างโดย AI ดูรายละเอียดจากแหล่งอ้างอิง", + "No bridges configured.": "ยังไม่ได้ตั้งค่าบริดจ์", + "Signing in to all bridges again...": "กำลังเข้าสู่ระบบบริดจ์ทั้งหมดอีกครั้ง…", + "Bridge status:": "สถานะบริดจ์:", + "Signed in ✓": "เข้าสู่ระบบแล้ว ✓", + "Not signed in ✗": "ยังไม่ได้เข้าสู่ระบบ ✗", + "None": "ไม่มี", + "Bridge {bridge}: skipped (login command or DM room missing).": "บริดจ์ {bridge}: ข้ามแล้ว (ไม่มีคำสั่งเข้าสู่ระบบหรือห้อง DM)", + "Bridge {bridge}: sending {command}...": "บริดจ์ {bridge}: กำลังส่ง {command}…", + "Bridge {bridge}: sign-out failed.": "บริดจ์ {bridge}: ออกจากระบบไม่สำเร็จ", + "Bridge {bridge}: sign-in started.": "บริดจ์ {bridge}: เริ่มเข้าสู่ระบบแล้ว", + "Slack authorization completed. Return to LangBot to finish setup.": "อนุญาต Slack สำเร็จแล้ว กลับไปที่ LangBot เพื่อดำเนินการตั้งค่าให้เสร็จ", + "Slack authorization failed or expired. Return to LangBot for details.": "อนุญาต Slack ไม่สำเร็จหรือหมดอายุ ดูรายละเอียดใน LangBot", + "TOTP is already enabled for this Account": "บัญชีนี้เปิดใช้ TOTP แล้ว", + "TOTP is not enabled for this Account": "บัญชีนี้ยังไม่ได้เปิดใช้ TOTP", + "Invalid or expired passkey challenge": "คำขอยืนยัน Passkey ไม่ถูกต้องหรือหมดอายุ", + "Passkey credential not recognized": "ไม่รู้จัก Passkey นี้", + "Account is disabled": "บัญชีถูกปิดใช้งาน", + "Modifying login info is disabled": "ไม่อนุญาตให้แก้ไขข้อมูลเข้าสู่ระบบ", + "Invalid GitHub repository URL": "URL ที่เก็บ GitHub ไม่ถูกต้อง", + "Server is busy. Please try again later.": "เซิร์ฟเวอร์ไม่ว่าง โปรดลองอีกครั้งภายหลัง", + "This feature is unavailable in this Workspace.": "พื้นที่ทำงานนี้ไม่สามารถใช้ฟีเจอร์นี้ได้", + "The Workspace must retain an owner.": "พื้นที่ทำงานต้องมีเจ้าของอย่างน้อยหนึ่งคน", + "Invalid or expired invitation": "คำเชิญไม่ถูกต้องหรือหมดอายุ", + "This user is already a member.": "ผู้ใช้นี้เป็นสมาชิกอยู่แล้ว", + "Voice connection failed": "การเชื่อมต่อเสียงล้มเหลว", + "User is not in the voice channel": "ผู้ใช้ไม่ได้อยู่ในช่องเสียง", + "Voice service is unavailable": "บริการเสียงไม่พร้อมใช้งาน", + "Submission failed. Please try again.": "ส่งไม่สำเร็จ โปรดลองอีกครั้ง", + "File size exceeds the limit.": "ขนาดไฟล์เกินขีดจำกัด", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "ไฟล์ใหญ่กว่า 10MB โปรดแบ่งเป็นไฟล์ย่อย", + "Authorization failed. Please try signing in again.": "การอนุญาตล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง", + "Bind the LangBot Account with the same email as this local Account": "เชื่อมโยงบัญชี LangBot ที่ใช้อีเมลเดียวกับบัญชีในระบบนี้", + "Only local accounts can bind to Space": "เฉพาะบัญชีในระบบเท่านั้นที่เชื่อมโยงกับ Space ได้", + "System already initialized": "ระบบเริ่มต้นแล้ว", + "System not initialized": "ระบบยังไม่ได้เริ่มต้น", + "Password login is disabled on LangBot Cloud": "LangBot Cloud ปิดการเข้าสู่ระบบด้วยรหัสผ่าน", + "This edition supports one Workspace per instance": "รุ่นนี้รองรับพื้นที่ทำงานหนึ่งแห่งต่ออินสแตนซ์", + "Cloud Workspaces are created by the SaaS control plane": "พื้นที่ทำงาน Cloud สร้างจากแผงควบคุม SaaS", + "Login with your LangBot Account to accept this invitation": "เข้าสู่ระบบด้วยบัญชี LangBot เพื่อรับคำเชิญ", + "RAG migration is not needed": "ไม่จำเป็นต้องย้าย RAG", + "New Agent": "Agent ใหม่", + "New Pipeline": "ไปป์ไลน์ใหม่", + "Provider {provider} does not support {model_type} models": "ผู้ให้บริการ {provider} ไม่รองรับโมเดล {model_type}", + "Invalid JSON format": "รูปแบบ JSON ไม่ถูกต้อง", + "ChatGPT sign-in required. Open this provider and sign in again.": "ต้องเข้าสู่ระบบ ChatGPT โปรดเปิดผู้ให้บริการนี้แล้วเข้าสู่ระบบอีกครั้ง", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "คำขอ ChatGPT ถูกจำกัดตามความถี่หรือปริมาณการใช้งาน โปรดลองภายหลังหรือตรวจสอบแผนของคุณ", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT ปฏิเสธคำขอ โปรดตรวจสอบการสมัครสมาชิกและสิทธิ์ของพื้นที่ทำงาน", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT ปฏิเสธโมเดลหรือคำขอ โปรดตรวจสอบโมเดลและการตั้งค่าคำขอ", + "ChatGPT Codex upstream request failed. Please retry later.": "คำขอไปยัง ChatGPT Codex ล้มเหลว โปรดลองอีกครั้งภายหลัง", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "การใช้งานสมาชิก ChatGPT ถึงขีดจำกัดแล้ว โปรดลองภายหลังหรือตรวจสอบแผนของคุณ", + "ChatGPT Codex response failed or was incomplete. Please retry.": "การตอบกลับของ ChatGPT Codex ล้มเหลวหรือไม่ครบถ้วน โปรดลองอีกครั้ง", + "ChatGPT token refresh temporarily failed. Please retry.": "ไม่สามารถรีเฟรชโทเค็น ChatGPT ได้ชั่วคราว โปรดลองอีกครั้ง", + "A second factor is required": "ต้องยืนยันตัวตนสองขั้นตอน", + "Sign in or provide registration details to accept this invitation": "เข้าสู่ระบบหรือกรอกข้อมูลลงทะเบียนเพื่อยอมรับคำเชิญนี้", + "Sign out before creating the invited local Account": "ออกจากระบบก่อนสร้างบัญชีภายในที่ได้รับเชิญ", + "Turnstile verification failed": "การยืนยัน Turnstile ล้มเหลว โปรดลองอีกครั้ง", + "The invitation email does not match your Account": "อีเมลคำเชิญไม่ตรงกับบัญชีของคุณ", + "Failed to save ChatGPT credentials. Please retry.": "บันทึกข้อมูลรับรอง ChatGPT ไม่สำเร็จ โปรดลองอีกครั้ง", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ไม่สามารถเริ่มเข้าสู่ระบบด้วยอุปกรณ์ได้ โปรดเปิดการเข้าสู่ระบบด้วยรหัสอุปกรณ์ในการตั้งค่า ChatGPT", + "Option {number}": "ตัวเลือก {number}", + "Confirmation needed": "ต้องยืนยัน", + "Session reset successfully": "รีเซ็ตเซสชันแล้ว" +} diff --git a/src/langbot/pkg/i18n/locales/vi_VN.json b/src/langbot/pkg/i18n/locales/vi_VN.json new file mode 100644 index 000000000..39b3110a8 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/vi_VN.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "Yêu cầu thất bại.", + "Too many requests. Please try again later.": "Quá nhiều yêu cầu. Vui lòng thử lại sau.", + "Your message contains inappropriate content. Please revise it.": "Tin nhắn chứa nội dung không phù hợp. Vui lòng chỉnh sửa.", + "Content checking failed. Please contact the administrator.": "Kiểm tra nội dung thất bại. Vui lòng liên hệ quản trị viên.", + "Calling {tools}...": "Đang gọi {tools}…", + "Agent runner not found: {runner}": "Không tìm thấy trình chạy Agent: {runner}", + "Agent runner not authorized for this pipeline.": "Pipeline này không được phép sử dụng trình chạy Agent.", + "Agent runner temporarily unavailable. Please try again.": "Trình chạy Agent tạm thời không khả dụng. Vui lòng thử lại.", + "Agent runner execution failed.": "Thực thi Agent thất bại.", + "The Agent runner returned an invalid response.": "Trình chạy Agent trả về phản hồi không hợp lệ.", + "Model request failed": "Yêu cầu đến mô hình thất bại", + "Context length exceeded": "Đã vượt quá độ dài ngữ cảnh", + "Invalid request parameters": "Tham số yêu cầu không hợp lệ", + "Invalid API key": "Khóa API không hợp lệ", + "Invalid model or endpoint": "Mô hình hoặc địa chỉ API không hợp lệ", + "Rate limit exceeded or insufficient balance": "Vượt giới hạn yêu cầu hoặc số dư không đủ", + "Request timed out": "Yêu cầu đã hết thời gian chờ", + "Connection failed": "Kết nối thất bại", + "API error": "Lỗi API", + "Unknown error": "Lỗi không xác định", + "Internal server error": "Lỗi máy chủ nội bộ", + "Resource not found": "Không tìm thấy tài nguyên", + "Permission denied": "Bạn không có quyền thực hiện thao tác này", + "Invalid authentication credentials": "Xác thực thất bại. Vui lòng đăng nhập lại", + "MCP runtime failed": "Thực thi MCP thất bại", + "Task execution failed": "Thực thi tác vụ thất bại", + "Task was cancelled": "Tác vụ đã bị hủy", + "Maximum number of {resource} ({limit}) reached": "Đã đạt số lượng tối đa của {resource} ({limit})", + "extensions": "tiện ích mở rộng", + "knowledge bases": "cơ sở tri thức", + "MCP server already exists: {name}": "Máy chủ MCP đã tồn tại: {name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models được quản lý bởi Cloud và không thể chỉnh sửa", + "Please sign in with your LangBot Account": "Vui lòng đăng nhập bằng tài khoản LangBot", + "Current password is incorrect": "Mật khẩu hiện tại không đúng", + "Invalid TOTP or recovery code": "Mã TOTP hoặc mã khôi phục không hợp lệ", + "No local password set, please set a password first": "Chưa có mật khẩu cục bộ. Vui lòng đặt mật khẩu trước", + "This LangBot Account is already bound to another user": "Tài khoản LangBot này đã được liên kết với người dùng khác", + "Invalid recovery key": "Khóa khôi phục không hợp lệ", + "Done": "Hoàn tất", + "Thank you for your feedback": "Cảm ơn bạn đã phản hồi", + "Failed to process feedback": "Không thể xử lý phản hồi", + "Helpful": "Hữu ích", + "Not helpful": "Không hữu ích", + "Select an option": "Chọn một mục", + "Enter a value": "Nhập giá trị", + "Submit": "Gửi", + "Submitted": "Đã gửi", + "Thinking": "Quá trình suy nghĩ", + "AI-generated": "Được tạo bởi AI", + "AI-generated content. Refer to the cited sources for details.": "Nội dung do AI tạo. Xem nguồn trích dẫn để biết chi tiết.", + "No bridges configured.": "Chưa cấu hình cầu nối nào.", + "Signing in to all bridges again...": "Đang đăng nhập lại vào tất cả cầu nối…", + "Bridge status:": "Trạng thái cầu nối:", + "Signed in ✓": "Đã đăng nhập ✓", + "Not signed in ✗": "Chưa đăng nhập ✗", + "None": "Không có", + "Bridge {bridge}: skipped (login command or DM room missing).": "Cầu nối {bridge}: đã bỏ qua (thiếu lệnh đăng nhập hoặc phòng DM).", + "Bridge {bridge}: sending {command}...": "Cầu nối {bridge}: đang gửi {command}…", + "Bridge {bridge}: sign-out failed.": "Cầu nối {bridge}: đăng xuất thất bại.", + "Bridge {bridge}: sign-in started.": "Cầu nối {bridge}: đã bắt đầu đăng nhập.", + "Slack authorization completed. Return to LangBot to finish setup.": "Đã cấp quyền Slack. Quay lại LangBot để hoàn tất thiết lập.", + "Slack authorization failed or expired. Return to LangBot for details.": "Cấp quyền Slack thất bại hoặc đã hết hạn. Xem chi tiết trong LangBot.", + "TOTP is already enabled for this Account": "Tài khoản này đã bật TOTP", + "TOTP is not enabled for this Account": "Tài khoản này chưa bật TOTP", + "Invalid or expired passkey challenge": "Yêu cầu xác minh passkey không hợp lệ hoặc đã hết hạn", + "Passkey credential not recognized": "Không nhận diện được passkey", + "Account is disabled": "Tài khoản đã bị vô hiệu hóa", + "Modifying login info is disabled": "Không được phép sửa thông tin đăng nhập", + "Invalid GitHub repository URL": "URL kho GitHub không hợp lệ", + "Server is busy. Please try again later.": "Máy chủ đang bận. Vui lòng thử lại sau.", + "This feature is unavailable in this Workspace.": "Tính năng này không khả dụng trong không gian làm việc này.", + "The Workspace must retain an owner.": "Không gian làm việc phải có ít nhất một chủ sở hữu.", + "Invalid or expired invitation": "Lời mời không hợp lệ hoặc đã hết hạn", + "This user is already a member.": "Người dùng này đã là thành viên.", + "Voice connection failed": "Kết nối thoại thất bại", + "User is not in the voice channel": "Người dùng không ở trong kênh thoại", + "Voice service is unavailable": "Dịch vụ thoại không khả dụng", + "Submission failed. Please try again.": "Gửi thất bại. Vui lòng thử lại.", + "File size exceeds the limit.": "Kích thước tệp vượt quá giới hạn.", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "Tệp vượt quá 10MB. Vui lòng chia thành các phần nhỏ hơn.", + "Authorization failed. Please try signing in again.": "Cấp quyền thất bại. Vui lòng đăng nhập lại.", + "Bind the LangBot Account with the same email as this local Account": "Liên kết tài khoản LangBot có cùng email với tài khoản cục bộ này", + "Only local accounts can bind to Space": "Chỉ tài khoản cục bộ mới có thể liên kết với Space", + "System already initialized": "Hệ thống đã được khởi tạo", + "System not initialized": "Hệ thống chưa được khởi tạo", + "Password login is disabled on LangBot Cloud": "LangBot Cloud không cho phép đăng nhập bằng mật khẩu", + "This edition supports one Workspace per instance": "Phiên bản này hỗ trợ một không gian làm việc cho mỗi phiên bản triển khai", + "Cloud Workspaces are created by the SaaS control plane": "Không gian làm việc Cloud được tạo từ bảng điều khiển SaaS", + "Login with your LangBot Account to accept this invitation": "Đăng nhập bằng tài khoản LangBot để chấp nhận lời mời", + "RAG migration is not needed": "Không cần di chuyển RAG", + "New Agent": "Agent mới", + "New Pipeline": "Pipeline mới", + "Provider {provider} does not support {model_type} models": "Nhà cung cấp {provider} không hỗ trợ mô hình {model_type}", + "Invalid JSON format": "Định dạng JSON không hợp lệ", + "ChatGPT sign-in required. Open this provider and sign in again.": "Cần đăng nhập ChatGPT. Mở nhà cung cấp này và đăng nhập lại.", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Yêu cầu ChatGPT bị giới hạn tần suất hoặc mức sử dụng. Hãy thử lại sau hoặc kiểm tra gói của bạn.", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT đã từ chối yêu cầu. Kiểm tra gói đăng ký và quyền của không gian làm việc.", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT đã từ chối mô hình hoặc yêu cầu. Kiểm tra mô hình đã chọn và cài đặt yêu cầu.", + "ChatGPT Codex upstream request failed. Please retry later.": "Yêu cầu tới ChatGPT Codex thất bại. Vui lòng thử lại sau.", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Đã đạt giới hạn sử dụng gói ChatGPT. Hãy thử lại sau hoặc kiểm tra gói của bạn.", + "ChatGPT Codex response failed or was incomplete. Please retry.": "Phản hồi ChatGPT Codex thất bại hoặc chưa hoàn tất. Vui lòng thử lại.", + "ChatGPT token refresh temporarily failed. Please retry.": "Tạm thời không thể làm mới token ChatGPT. Vui lòng thử lại.", + "A second factor is required": "Cần xác thực hai yếu tố", + "Sign in or provide registration details to accept this invitation": "Đăng nhập hoặc cung cấp thông tin đăng ký để chấp nhận lời mời", + "Sign out before creating the invited local Account": "Đăng xuất trước khi tạo tài khoản cục bộ được mời", + "Turnstile verification failed": "Xác minh Turnstile thất bại. Vui lòng thử lại.", + "The invitation email does not match your Account": "Email trong lời mời không khớp với tài khoản của bạn", + "Failed to save ChatGPT credentials. Please retry.": "Không thể lưu thông tin xác thực ChatGPT. Vui lòng thử lại.", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Không thể bắt đầu đăng nhập thiết bị ChatGPT. Hãy bật đăng nhập bằng mã thiết bị trong cài đặt ChatGPT.", + "Option {number}": "Lựa chọn {number}", + "Confirmation needed": "Cần xác nhận", + "Session reset successfully": "Đã đặt lại phiên" +} diff --git a/src/langbot/pkg/i18n/locales/zh_Hans.json b/src/langbot/pkg/i18n/locales/zh_Hans.json new file mode 100644 index 000000000..29a12e840 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/zh_Hans.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "请求失败。", + "Too many requests. Please try again later.": "请求过于频繁,请稍后重试。", + "Your message contains inappropriate content. Please revise it.": "消息中存在不合适的内容,请修改。", + "Content checking failed. Please contact the administrator.": "内容检查失败,请联系管理员。", + "Calling {tools}...": "正在调用 {tools}…", + "Agent runner not found: {runner}": "找不到 Agent 运行器:{runner}", + "Agent runner not authorized for this pipeline.": "此流水线未获授权使用该 Agent 运行器。", + "Agent runner temporarily unavailable. Please try again.": "Agent 运行器暂时不可用,请重试。", + "Agent runner execution failed.": "Agent 执行失败。", + "The Agent runner returned an invalid response.": "Agent 运行器返回了无效响应。", + "Model request failed": "模型请求失败", + "Context length exceeded": "上下文长度超限", + "Invalid request parameters": "请求参数错误", + "Invalid API key": "API 密钥无效", + "Invalid model or endpoint": "模型或接口地址无效", + "Rate limit exceeded or insufficient balance": "请求过于频繁或余额不足", + "Request timed out": "请求超时", + "Connection failed": "连接失败", + "API error": "API 错误", + "Unknown error": "未知错误", + "Internal server error": "服务器内部错误", + "Resource not found": "资源不存在", + "Permission denied": "没有执行此操作的权限", + "Invalid authentication credentials": "身份验证失败,请重新登录", + "MCP runtime failed": "MCP 运行失败", + "Task execution failed": "任务执行失败", + "Task was cancelled": "任务已取消", + "Maximum number of {resource} ({limit}) reached": "{resource}数量已达到上限({limit})", + "extensions": "扩展", + "knowledge bases": "知识库", + "MCP server already exists: {name}": "MCP 服务器已存在:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由云端管理,无法修改", + "Please sign in with your LangBot Account": "请使用 LangBot 账号登录", + "Current password is incorrect": "当前密码不正确", + "Invalid TOTP or recovery code": "验证码或恢复码无效", + "No local password set, please set a password first": "尚未设置本地密码,请先设置密码", + "This LangBot Account is already bound to another user": "此 LangBot 账号已绑定其他用户", + "Invalid recovery key": "恢复密钥无效", + "Done": "操作成功", + "Thank you for your feedback": "感谢您的反馈", + "Failed to process feedback": "反馈处理失败", + "Helpful": "有帮助", + "Not helpful": "无帮助", + "Select an option": "请选择", + "Enter a value": "请输入", + "Submit": "提交", + "Submitted": "已提交", + "Thinking": "思考过程", + "AI-generated": "内容由 AI 生成", + "AI-generated content. Refer to the cited sources for details.": "内容由 AI 生成,详情请参考引用来源。", + "No bridges configured.": "没有配置任何桥。", + "Signing in to all bridges again...": "正在重新登录所有桥…", + "Bridge status:": "桥状态:", + "Signed in ✓": "已登录 ✓", + "Not signed in ✗": "未登录 ✗", + "None": "无", + "Bridge {bridge}: skipped (login command or DM room missing).": "桥 {bridge}:已跳过(缺少登录命令或私聊房间)。", + "Bridge {bridge}: sending {command}...": "桥 {bridge}:正在发送 {command}…", + "Bridge {bridge}: sign-out failed.": "桥 {bridge}:退出登录失败。", + "Bridge {bridge}: sign-in started.": "桥 {bridge}:已开始登录。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack 授权完成,请返回 LangBot 完成配置。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack 授权失败或已过期,请返回 LangBot 查看详情。", + "TOTP is already enabled for this Account": "此账号已启用两步验证", + "TOTP is not enabled for this Account": "此账号尚未启用两步验证", + "Invalid or expired passkey challenge": "通行密钥验证请求无效或已过期", + "Passkey credential not recognized": "无法识别此通行密钥", + "Account is disabled": "账号已停用", + "Modifying login info is disabled": "不允许修改登录信息", + "Invalid GitHub repository URL": "GitHub 仓库地址无效", + "Server is busy. Please try again later.": "服务器繁忙,请稍后重试。", + "This feature is unavailable in this Workspace.": "此工作区无法使用该功能。", + "The Workspace must retain an owner.": "工作区必须保留一位所有者。", + "Invalid or expired invitation": "邀请无效或已过期", + "This user is already a member.": "此用户已是工作区成员。", + "Voice connection failed": "语音连接失败", + "User is not in the voice channel": "用户不在语音频道中", + "Voice service is unavailable": "语音服务不可用", + "Submission failed. Please try again.": "提交失败,请重试。", + "File size exceeds the limit.": "文件大小超过限制。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "文件大小超过 10MB,请拆分后上传。", + "Authorization failed. Please try signing in again.": "授权失败,请重新登录。", + "Bind the LangBot Account with the same email as this local Account": "请绑定与此本地账号邮箱相同的 LangBot 账号", + "Only local accounts can bind to Space": "只有本地账号可以绑定 Space", + "System already initialized": "系统已初始化", + "System not initialized": "系统尚未初始化", + "Password login is disabled on LangBot Cloud": "LangBot Cloud 不支持密码登录", + "This edition supports one Workspace per instance": "此版本每个实例仅支持一个工作区", + "Cloud Workspaces are created by the SaaS control plane": "云工作区由 SaaS 控制台创建", + "Login with your LangBot Account to accept this invitation": "请登录 LangBot 账号以接受邀请", + "RAG migration is not needed": "无需迁移知识库", + "New Agent": "新 Agent", + "New Pipeline": "新流水线", + "Provider {provider} does not support {model_type} models": "提供商 {provider} 不支持 {model_type} 模型", + "Invalid JSON format": "JSON 格式无效", + "ChatGPT sign-in required. Open this provider and sign in again.": "需要登录 ChatGPT。请打开此提供商并重新登录。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 请求受限(请求频率或用量限制)。请稍后重试或检查套餐。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒绝了此请求。请检查订阅和工作区权限。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒绝了模型或请求。请检查所选模型和请求设置。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游请求失败,请稍后重试。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 订阅用量已达上限。请稍后重试或检查套餐。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 响应失败或不完整,请重试。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 令牌暂时刷新失败,请重试。", + "A second factor is required": "需要二次验证", + "Sign in or provide registration details to accept this invitation": "请登录或填写注册信息以接受此邀请", + "Sign out before creating the invited local Account": "请先退出登录,再创建受邀本地账号", + "Turnstile verification failed": "人机验证失败,请重试", + "The invitation email does not match your Account": "邀请邮箱与您的账号不匹配", + "Failed to save ChatGPT credentials. Please retry.": "保存 ChatGPT 凭据失败,请重试。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "无法启动 ChatGPT 设备登录。请在 ChatGPT 设置中启用设备代码登录。", + "Option {number}": "选项 {number}", + "Confirmation needed": "需要确认", + "Session reset successfully": "会话已重置" +} diff --git a/src/langbot/pkg/i18n/locales/zh_Hant.json b/src/langbot/pkg/i18n/locales/zh_Hant.json new file mode 100644 index 000000000..15f5e90c3 --- /dev/null +++ b/src/langbot/pkg/i18n/locales/zh_Hant.json @@ -0,0 +1,114 @@ +{ + "Request failed.": "請求失敗。", + "Too many requests. Please try again later.": "請求過於頻繁,請稍後重試。", + "Your message contains inappropriate content. Please revise it.": "訊息中存在不適當的內容,請修改。", + "Content checking failed. Please contact the administrator.": "內容檢查失敗,請聯絡管理員。", + "Calling {tools}...": "正在呼叫 {tools}…", + "Agent runner not found: {runner}": "找不到 Agent 執行器:{runner}", + "Agent runner not authorized for this pipeline.": "此流水線未獲授權使用該 Agent 執行器。", + "Agent runner temporarily unavailable. Please try again.": "Agent 執行器暫時無法使用,請重試。", + "Agent runner execution failed.": "Agent 執行失敗。", + "The Agent runner returned an invalid response.": "Agent 執行器傳回了無效回應。", + "Model request failed": "模型請求失敗", + "Context length exceeded": "上下文長度超出限制", + "Invalid request parameters": "請求參數錯誤", + "Invalid API key": "API 金鑰無效", + "Invalid model or endpoint": "模型或介面位址無效", + "Rate limit exceeded or insufficient balance": "請求過於頻繁或餘額不足", + "Request timed out": "請求逾時", + "Connection failed": "連線失敗", + "API error": "API 錯誤", + "Unknown error": "未知錯誤", + "Internal server error": "伺服器內部錯誤", + "Resource not found": "資源不存在", + "Permission denied": "沒有執行此操作的權限", + "Invalid authentication credentials": "身分驗證失敗,請重新登入", + "MCP runtime failed": "MCP 執行失敗", + "Task execution failed": "任務執行失敗", + "Task was cancelled": "任務已取消", + "Maximum number of {resource} ({limit}) reached": "{resource}數量已達上限({limit})", + "extensions": "擴充功能", + "knowledge bases": "知識庫", + "MCP server already exists: {name}": "MCP 伺服器已存在:{name}", + "LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由雲端管理,無法修改", + "Please sign in with your LangBot Account": "請使用 LangBot 帳號登入", + "Current password is incorrect": "目前密碼不正確", + "Invalid TOTP or recovery code": "驗證碼或復原碼無效", + "No local password set, please set a password first": "尚未設定本機密碼,請先設定密碼", + "This LangBot Account is already bound to another user": "此 LangBot 帳號已綁定其他使用者", + "Invalid recovery key": "復原金鑰無效", + "Done": "操作成功", + "Thank you for your feedback": "感謝您的回饋", + "Failed to process feedback": "回饋處理失敗", + "Helpful": "有幫助", + "Not helpful": "沒有幫助", + "Select an option": "請選擇", + "Enter a value": "請輸入", + "Submit": "提交", + "Submitted": "已提交", + "Thinking": "思考過程", + "AI-generated": "內容由 AI 產生", + "AI-generated content. Refer to the cited sources for details.": "內容由 AI 產生,詳情請參考引用來源。", + "No bridges configured.": "尚未設定任何橋接。", + "Signing in to all bridges again...": "正在重新登入所有橋接…", + "Bridge status:": "橋接狀態:", + "Signed in ✓": "已登入 ✓", + "Not signed in ✗": "未登入 ✗", + "None": "無", + "Bridge {bridge}: skipped (login command or DM room missing).": "橋接 {bridge}:已略過(缺少登入命令或私訊房間)。", + "Bridge {bridge}: sending {command}...": "橋接 {bridge}:正在傳送 {command}…", + "Bridge {bridge}: sign-out failed.": "橋接 {bridge}:登出失敗。", + "Bridge {bridge}: sign-in started.": "橋接 {bridge}:已開始登入。", + "Slack authorization completed. Return to LangBot to finish setup.": "Slack 授權完成,請返回 LangBot 完成設定。", + "Slack authorization failed or expired. Return to LangBot for details.": "Slack 授權失敗或已過期,請返回 LangBot 查看詳情。", + "TOTP is already enabled for this Account": "此帳號已啟用兩步驗證", + "TOTP is not enabled for this Account": "此帳號尚未啟用兩步驗證", + "Invalid or expired passkey challenge": "通行金鑰驗證請求無效或已過期", + "Passkey credential not recognized": "無法識別此通行金鑰", + "Account is disabled": "帳號已停用", + "Modifying login info is disabled": "不允許修改登入資訊", + "Invalid GitHub repository URL": "GitHub 儲存庫網址無效", + "Server is busy. Please try again later.": "伺服器忙碌中,請稍後重試。", + "This feature is unavailable in this Workspace.": "此工作區無法使用該功能。", + "The Workspace must retain an owner.": "工作區必須保留一位擁有者。", + "Invalid or expired invitation": "邀請無效或已過期", + "This user is already a member.": "此使用者已是工作區成員。", + "Voice connection failed": "語音連線失敗", + "User is not in the voice channel": "使用者不在語音頻道中", + "Voice service is unavailable": "語音服務無法使用", + "Submission failed. Please try again.": "提交失敗,請重試。", + "File size exceeds the limit.": "檔案大小超過限制。", + "File size exceeds 10MB limit. Please split large files into smaller parts.": "檔案大小超過 10MB,請分割後上傳。", + "Authorization failed. Please try signing in again.": "授權失敗,請重新登入。", + "Bind the LangBot Account with the same email as this local Account": "請綁定與此本機帳號電子郵件相同的 LangBot 帳號", + "Only local accounts can bind to Space": "只有本機帳號可以綁定 Space", + "System already initialized": "系統已初始化", + "System not initialized": "系統尚未初始化", + "Password login is disabled on LangBot Cloud": "LangBot Cloud 不支援密碼登入", + "This edition supports one Workspace per instance": "此版本每個執行個體僅支援一個工作區", + "Cloud Workspaces are created by the SaaS control plane": "雲端工作區由 SaaS 控制台建立", + "Login with your LangBot Account to accept this invitation": "請登入 LangBot 帳號以接受邀請", + "RAG migration is not needed": "無需遷移知識庫", + "New Agent": "新 Agent", + "New Pipeline": "新流水線", + "Provider {provider} does not support {model_type} models": "提供商 {provider} 不支援 {model_type} 模型", + "Invalid JSON format": "JSON 格式無效", + "ChatGPT sign-in required. Open this provider and sign in again.": "需要登入 ChatGPT。請開啟此供應商並重新登入。", + "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 請求受限(請求頻率或用量限制)。請稍後重試或檢查方案。", + "ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒絕了此請求。請檢查訂閱和工作區權限。", + "ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒絕了模型或請求。請檢查所選模型和請求設定。", + "ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游請求失敗,請稍後重試。", + "ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 訂閱用量已達上限。請稍後重試或檢查方案。", + "ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 回應失敗或不完整,請重試。", + "ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 權杖暫時更新失敗,請重試。", + "A second factor is required": "需要雙重驗證", + "Sign in or provide registration details to accept this invitation": "請登入或填寫註冊資料以接受此邀請", + "Sign out before creating the invited local Account": "請先登出,再建立受邀本機帳號", + "Turnstile verification failed": "人機驗證失敗,請重試", + "The invitation email does not match your Account": "邀請信箱與您的帳號不符", + "Failed to save ChatGPT credentials. Please retry.": "儲存 ChatGPT 憑證失敗,請重試。", + "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "無法啟動 ChatGPT 裝置登入。請在 ChatGPT 設定中啟用裝置代碼登入。", + "Option {number}": "選項 {number}", + "Confirmation needed": "需要確認", + "Session reset successfully": "對話已重設" +} diff --git a/src/langbot/pkg/i18n/workspace.py b/src/langbot/pkg/i18n/workspace.py new file mode 100644 index 000000000..0527b818b --- /dev/null +++ b/src/langbot/pkg/i18n/workspace.py @@ -0,0 +1,71 @@ +"""Workspace-owned message language, stored in the existing tenant metadata.""" + +from __future__ import annotations + +import time +from collections import OrderedDict + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from ..entity.persistence.metadata import WorkspaceMetadata +from . import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, normalize_language + +LANGUAGE_KEY = 'message_language' +_CACHE_TTL = 30 +_CACHE_SIZE = 256 + + +def _cache(ap) -> OrderedDict: + cache = getattr(ap, '_message_language_cache', None) + if cache is None: + cache = OrderedDict() + ap._message_language_cache = cache + return cache + + +async def get_workspace_language(ap, workspace_uuid: str, *, refresh: bool = False) -> str: + if not workspace_uuid: + return DEFAULT_LANGUAGE + cache = _cache(ap) + cached = cache.get(workspace_uuid) + if not refresh and cached and cached[1] > time.monotonic(): + return cached[0] + manager = getattr(ap, 'persistence_mgr', None) + if manager is None or not callable(getattr(manager, 'tenant_uow', None)): + return DEFAULT_LANGUAGE + async with manager.tenant_uow(workspace_uuid) as uow: + result = await uow.execute( + sa.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == workspace_uuid, + WorkspaceMetadata.key == LANGUAGE_KEY, + ) + ) + locale = normalize_language(result.scalar_one_or_none()) + cache[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL) + cache.move_to_end(workspace_uuid) + while len(cache) > _CACHE_SIZE: + cache.popitem(last=False) + return locale + + +async def set_workspace_language(ap, workspace_uuid: str, locale: str) -> str: + if locale not in SUPPORTED_LANGUAGES: + raise ValueError('Unsupported Workspace language') + async with ap.persistence_mgr.tenant_uow(workspace_uuid) as uow: + dialect = ap.persistence_mgr.get_db_engine().dialect.name + insert = pg_insert if dialect == 'postgresql' else sqlite_insert + statement = insert(WorkspaceMetadata).values(workspace_uuid=workspace_uuid, key=LANGUAGE_KEY, value=locale) + await uow.execute( + statement.on_conflict_do_update( + index_elements=['workspace_uuid', 'key'], + set_={'value': locale}, + ) + ) + # Synchronous platform callbacks may run in SDK threads. Publish the new + # value only after commit, so they see changes without restarting a bot. + _cache(ap)[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL) + while len(_cache(ap)) > _CACHE_SIZE: + _cache(ap).popitem(last=False) + return locale diff --git a/src/langbot/pkg/persistence/alembic/versions/0034_monitoring_execution_links.py b/src/langbot/pkg/persistence/alembic/versions/0034_monitoring_execution_links.py new file mode 100644 index 000000000..4cc720d07 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0034_monitoring_execution_links.py @@ -0,0 +1,36 @@ +"""Link monitoring inputs and deliveries to events and runs.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0034_monitoring_execution_links' +down_revision = '0033_operation_traceability' +branch_labels = None +depends_on = None + + +def upgrade(): + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table('monitoring_messages'): + return + columns = {c['name'] for c in inspector.get_columns('monitoring_messages')} + indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')} + for name in ('event_id', 'run_id', 'parent_message_id'): + if name not in columns: + op.add_column('monitoring_messages', sa.Column(name, sa.String(255), nullable=True)) + index = f'ix_monitoring_messages_{name}' + if index not in indexes: + op.create_index(index, 'monitoring_messages', [name]) + + +def downgrade(): + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table('monitoring_messages'): + return + columns = {c['name'] for c in inspector.get_columns('monitoring_messages')} + indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')} + for name in ('parent_message_id', 'run_id', 'event_id'): + if f'ix_monitoring_messages_{name}' in indexes: + op.drop_index(f'ix_monitoring_messages_{name}', table_name='monitoring_messages') + if name in columns: + op.drop_column('monitoring_messages', name) diff --git a/src/langbot/pkg/persistence/alembic/versions/0035_pipeline_received_only.py b/src/langbot/pkg/persistence/alembic/versions/0035_pipeline_received_only.py new file mode 100644 index 000000000..146d0c754 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0035_pipeline_received_only.py @@ -0,0 +1,39 @@ +"""Narrow legacy Pipeline wildcard routes to incoming messages.""" + +import json + +from alembic import op +import sqlalchemy as sa + +revision = '0035_pipeline_received_only' +down_revision = '0034_monitoring_execution_links' +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + if not sa.inspect(connection).has_table('bots'): + return + bots = sa.table('bots', sa.column('uuid', sa.String()), sa.column('event_bindings', sa.JSON())) + for bot_uuid, bindings in connection.execute(sa.select(bots.c.uuid, bots.c.event_bindings)).all(): + if isinstance(bindings, str): + bindings = json.loads(bindings) + if not isinstance(bindings, list): + continue + changed = False + for binding in bindings: + if ( + isinstance(binding, dict) + and binding.get('target_type') == 'pipeline' + and binding.get('event_pattern') == 'message.*' + ): + binding['event_pattern'] = 'message.received' + changed = True + if changed: + connection.execute(bots.update().where(bots.c.uuid == bot_uuid).values(event_bindings=bindings)) + + +def downgrade(): + # An exact incoming-message route remains valid with older versions. + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0036_execution_host_leases.py b/src/langbot/pkg/persistence/alembic/versions/0036_execution_host_leases.py new file mode 100644 index 000000000..120d77275 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0036_execution_host_leases.py @@ -0,0 +1,41 @@ +"""Track the liveness of hosts owning direct executions.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0036_execution_host_leases' +down_revision = '0035_pipeline_received_only' +branch_labels = None +depends_on = None + + +def upgrade(): + inspector = sa.inspect(op.get_bind()) + for table in ('agent_run', 'monitoring_messages'): + if not inspector.has_table(table): + continue + columns = {c['name'] for c in inspector.get_columns(table)} + indexes = {i['name'] for i in inspector.get_indexes(table)} + for column, column_type in ( + ('execution_owner_id', sa.String(36)), + ('execution_lease_expires_at', sa.DateTime()), + ): + if column not in columns: + op.add_column(table, sa.Column(column, column_type, nullable=True)) + index = f'ix_{table}_{column}' + if index not in indexes: + op.create_index(index, table, [column]) + + +def downgrade(): + inspector = sa.inspect(op.get_bind()) + for table in ('agent_run', 'monitoring_messages'): + if not inspector.has_table(table): + continue + columns = {c['name'] for c in inspector.get_columns(table)} + indexes = {i['name'] for i in inspector.get_indexes(table)} + for column in ('execution_owner_id', 'execution_lease_expires_at'): + if f'ix_{table}_{column}' in indexes: + op.drop_index(f'ix_{table}_{column}', table_name=table) + if column in columns: + op.drop_column(table, column) diff --git a/src/langbot/pkg/persistence/alembic/versions/0037_metadata_text.py b/src/langbot/pkg/persistence/alembic/versions/0037_metadata_text.py new file mode 100644 index 000000000..dd6922cb4 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0037_metadata_text.py @@ -0,0 +1,22 @@ +"""Allow structured wizard progress in metadata values.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0037_metadata_text' +down_revision = '0036_execution_host_leases' +branch_labels = None +depends_on = None + + +def upgrade(): + inspector = sa.inspect(op.get_bind()) + for table in ('metadata', 'workspace_metadata'): + if inspector.has_table(table): + with op.batch_alter_table(table) as batch: + batch.alter_column('value', existing_type=sa.String(255), type_=sa.Text(), existing_nullable=True) + + +def downgrade(): + # Keep the widened type: narrowing would destroy valid saved progress. + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0038_llm_call_details.py b/src/langbot/pkg/persistence/alembic/versions/0038_llm_call_details.py new file mode 100644 index 000000000..a91469f23 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0038_llm_call_details.py @@ -0,0 +1,25 @@ +"""Persist additive LLM timing, usage and structured failure diagnostics.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0038_llm_call_details' +down_revision = '0037_metadata_text' +branch_labels = None +depends_on = None + + +def upgrade(): + inspector = sa.inspect(op.get_bind()) + if inspector.has_table('monitoring_llm_calls'): + columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')} + if 'details' not in columns: + op.add_column('monitoring_llm_calls', sa.Column('details', sa.JSON(), nullable=True)) + + +def downgrade(): + inspector = sa.inspect(op.get_bind()) + if inspector.has_table('monitoring_llm_calls'): + columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')} + if 'details' in columns: + op.drop_column('monitoring_llm_calls', 'details') diff --git a/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py b/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py new file mode 100644 index 000000000..eba466381 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0039_execution_lease_indexes.py @@ -0,0 +1,47 @@ +"""Index execution leases, conversation paging, and historical inline image expiry.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0039_execution_lease_indexes' +down_revision = '0038_llm_call_details' +branch_labels = None +depends_on = None + +INDEXES = ( + ('agent_run', 'ix_agent_run_workspace_status_lease', ['workspace_id', 'status', 'execution_lease_expires_at']), + ( + 'monitoring_messages', + 'ix_monitoring_messages_workspace_status_lease', + ['workspace_uuid', 'status', 'execution_lease_expires_at'], + ), + ( + 'monitoring_messages', + 'ix_monitoring_messages_conversation_page', + ['workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'], + ), + ('monitoring_messages', 'ix_monitoring_messages_media_ttl', ['workspace_uuid', 'timestamp', 'id']), + ('event_log', 'ix_event_log_media_ttl', ['workspace_id', 'created_at', 'id']), + ('agent_run', 'ix_agent_run_media_ttl', ['workspace_id', 'created_at', 'id']), + ('agent_run_event', 'ix_agent_run_event_media_ttl', ['created_at', 'id', 'run_id']), + ('transcript', 'ix_transcript_media_ttl', ['workspace_id', 'created_at', 'id']), +) + + +def upgrade(): + inspector = sa.inspect(op.get_bind()) + for table, index, columns in INDEXES: + if not inspector.has_table(table): + continue + # Fresh installations create these indexes from ORM metadata before migrations. + if index not in {item['name'] for item in inspector.get_indexes(table)}: + op.create_index(index, table, columns) + + +def downgrade(): + inspector = sa.inspect(op.get_bind()) + for table, index, _ in reversed(INDEXES): + if not inspector.has_table(table): + continue + if index in {item['name'] for item in inspector.get_indexes(table)}: + op.drop_index(index, table_name=table) diff --git a/src/langbot/pkg/persistence/databases/sqlite.py b/src/langbot/pkg/persistence/databases/sqlite.py index ed096040f..8080d20d8 100644 --- a/src/langbot/pkg/persistence/databases/sqlite.py +++ b/src/langbot/pkg/persistence/databases/sqlite.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sqlalchemy import sqlalchemy.ext.asyncio as sqlalchemy_asyncio from .. import database @@ -13,3 +14,8 @@ class SQLiteDatabaseManager(database.BaseDatabaseManager): db_file_path = self.ap.instance_config.data.get('database', {}).get('sqlite', {}).get('path', 'data/langbot.db') engine_url = f'sqlite+aiosqlite:///{db_file_path}' self.engine = sqlalchemy_asyncio.create_async_engine(engine_url) + # Reader transactions must not block independent plugin callback commits. + # Configure the persistent journal before serving requests, not per write. + async with self.engine.connect() as connection: + await connection.execute(sqlalchemy.text('PRAGMA journal_mode=WAL')) + await connection.commit() diff --git a/src/langbot/pkg/persistence/media.py b/src/langbot/pkg/persistence/media.py new file mode 100644 index 000000000..337c63834 --- /dev/null +++ b/src/langbot/pkg/persistence/media.py @@ -0,0 +1,46 @@ +"""The storage contract for conversation payloads containing image references.""" + +from __future__ import annotations + +import json +from typing import Any + +from sqlalchemy import Text +from sqlalchemy.engine import Dialect +from sqlalchemy.sql.operators import OperatorType +from sqlalchemy.types import TypeDecorator + + +class MediaReferenceText(TypeDecorator[str]): + """Keep the existing TEXT format while rejecting structured inline images. + + Writers must externalize retained images through MediaCache or redact + metadata-only images before binding a value. Both ORM and Core writes use + this contract; reads preserve historical values for incremental cleanup. + Plain text remains valid for fields such as monitoring message content. + """ + + impl = Text + cache_ok = True + + def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None: + if value is None: + return None + try: + payload = json.loads(value) + except json.JSONDecodeError: + return value + + # Model imports must not initialize the media cache's runtime dependencies. + from ..storage.media import MediaCache + + if MediaCache.has_inline_images(payload): + raise ValueError( + 'Inline images cannot be persisted; use MediaCache.externalize_chain_dump() ' + 'for retained content or MediaCache.redact_inline_images() for metadata' + ) + return value + + def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Text: + # Legacy payloads must remain usable in searches and compare-and-swap updates. + return self.impl diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 0eacc85c5..c55b7a252 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -19,6 +19,8 @@ from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLO from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate +from .media import MediaReferenceText + TENANT_SETTING = 'langbot.workspace_uuid' ACCOUNT_SETTING = 'langbot.account_uuid' @@ -218,7 +220,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = { 'now': sqlalchemy.sql.functions.now, 'sum': sqlalchemy.sql.functions.sum, } -_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'}) +_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'row_number', 'strftime'}) _ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'}) _ALLOWED_SCOPED_STATEMENT_TYPES = ( sqlalchemy.sql.dml.UpdateBase, @@ -292,7 +294,7 @@ def _validate_scoped_sql_type( if type(sql_type) in {Vector, HALFVEC}: return - if not type(sql_type).__module__.startswith('sqlalchemy.'): + if type(sql_type) is not MediaReferenceText and not type(sql_type).__module__.startswith('sqlalchemy.'): raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements') for identifier_attribute in ('name', 'schema', 'collation'): @@ -483,9 +485,18 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines' and source.name in {'config', 'stages', 'extensions_preferences'} ) - if not pipeline_json_cas: + monitoring_metadata = ( + type(element.type) is sqlalchemy.JSON + and isinstance(source, sqlalchemy.Column) + and type(source.type) in {sqlalchemy.Text, MediaReferenceText} + and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'} + and ( + source.name == 'metadata_json' or (source.table.name == 'event_log' and source.name == 'input_json') + ) + ) + if not (pipeline_json_cas or monitoring_metadata): raise ScopedSessionTransactionError( - 'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts' + 'TenantUnitOfWork only allows trusted pgvector, Pipeline JSON CAS and monitoring metadata casts' ) if isinstance(element, sqlalchemy.sql.functions.FunctionElement): diff --git a/src/langbot/pkg/pipeline/aggregator.py b/src/langbot/pkg/pipeline/aggregator.py index acf0a29f7..32ec748b6 100644 --- a/src/langbot/pkg/pipeline/aggregator.py +++ b/src/langbot/pkg/pipeline/aggregator.py @@ -162,6 +162,13 @@ class MessageAggregator: if variables: enabled = False + # Preserve ingress identity across the queue and detached debounce task. + from ..telemetry import trace + + ingress_trace = trace.current() + if ingress_trace is not None: + variables = {**(variables or {}), '_ingress_event_id': ingress_trace.event_id} + if not enabled: await self.ap.query_pool.add_query( bot_uuid=bot_uuid, diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py index 8c74150b0..444fd4798 100644 --- a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py +++ b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import pipeline_notice + from .. import entities from .. import filter as filter_model import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -58,6 +60,10 @@ class BaiduCloudExamine(filter_model.ContentFilter): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement=message, - user_notice='消息中存在不合适的内容, 请修改', + user_notice=pipeline_notice( + query, + 'content-filter-hint', + 'Your message contains inappropriate content. Please revise it.', + ), console_notice=f'百度云判定结果:{conclusion}', ) diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py index bfd8ca772..32a3a5f30 100644 --- a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py +++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import pipeline_notice + from .. import filter as filter_model from .. import entities import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -35,13 +37,19 @@ class BanWordFilter(filter_model.ContentFilter): return entities.FilterResult( level=entities.ResultLevel.BLOCK, replacement='', - user_notice='内容检查规则执行失败,请联系管理员', + user_notice=pipeline_notice( + query, 'content-filter-error-hint', 'Content checking failed. Please contact the administrator.' + ), console_notice=f'Sensitive-word regex rejected: {exc}', ) return entities.FilterResult( level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS, replacement=current, - user_notice='消息中存在不合适的内容, 请修改' if found else '', + user_notice=pipeline_notice( + query, 'content-filter-hint', 'Your message contains inappropriate content. Please revise it.' + ) + if found + else '', console_notice='', ) diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index a3df33af0..7c44d05c0 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -19,18 +19,21 @@ import re from urllib.parse import urlsplit from string import Formatter -PLANNER_VERSION = '4' +PLANNER_VERSION = '5' +# Minimum reviewed plugin versions. The marketplace delists old releases, so the +# installer falls back to the latest release when a pin is gone; the Host then +# validates the emitted config against the installed runner schema. _TARGETS = { - 'local-agent': ('LocalAgent', '0.1.10'), - 'dify-service-api': ('DifyAgent', '0.1.10'), - 'coze-api': ('CozeAgent', '0.1.10'), - 'dashscope-app-api': ('DashScopeAgent', '0.1.10'), - 'n8n-service-api': ('N8nAgent', '0.1.10'), - 'langflow-api': ('LangflowAgent', '0.1.10'), - 'deerflow-api': ('DeerFlowAgent', '0.1.10'), - 'tbox-app-api': ('TboxAgent', '0.1.8'), - 'weknora-api': ('WeKnoraAgent', '0.1.10'), + 'local-agent': ('LocalAgent', '0.2.1'), + 'dify-service-api': ('DifyAgent', '0.2.3'), + 'coze-api': ('CozeAgent', '0.2.0'), + 'dashscope-app-api': ('DashScopeAgent', '0.2.2'), + 'n8n-service-api': ('N8nAgent', '0.2.0'), + 'langflow-api': ('LangflowAgent', '0.2.2'), + 'deerflow-api': ('DeerFlowAgent', '0.2.1'), + 'tbox-app-api': ('TboxAgent', '0.2.1'), + 'weknora-api': ('WeKnoraAgent', '0.2.1'), } _DEERFLOW_FIELDS = { 'api-base', @@ -192,6 +195,51 @@ _DEFAULTS = { 'advanced-settings': False, }, } +# Missing values for these fields cannot be invented: they are credentials or +# endpoints. Everything else is repaired from reviewed 4.10 defaults. +_CRITICAL = { + 'local-agent': ('model',), + 'dify-service-api': ('base-url', 'api-key'), + 'coze-api': ('api-key', 'bot-id'), + 'dashscope-app-api': ('api-key', 'app-id'), + 'n8n-service-api': ('webhook-url',), + 'langflow-api': ('base-url', 'api-key', 'flow-id'), + 'deerflow-api': ('api-base',), + 'tbox-app-api': ('api-key', 'app-id'), + 'weknora-api': ('base-url', 'api-key'), +} +_LOCAL_PROMPT = [{'role': 'system', 'content': 'You are a helpful assistant.'}] +# 4.10 UI defaults for required, non-critical fields. +_FILL = { + 'local-agent': {'prompt': _LOCAL_PROMPT, 'box-session-id-template': '{launcher_type}_{launcher_id}'}, + 'dify-service-api': { + 'app-type': 'chat', + 'base-prompt': ( + 'When the file content is readable, please read the content of this file. ' + 'When the file is an image, describe the content of this image.' + ), + }, + 'coze-api': {'api-base': 'https://api.coze.cn', 'timeout': 120}, + 'dashscope-app-api': {'app-type': 'agent'}, + 'weknora-api': {'app-type': 'chat'}, +} +_INT_TIMEOUT = {'deerflow-api', 'n8n-service-api', 'dify-service-api'} +_TIMEOUT_DEFAULT = { + 'dify-service-api': 30, + 'coze-api': 120, + 'dashscope-app-api': 120, + 'n8n-service-api': 120, + 'deerflow-api': 300, + 'tbox-app-api': 120, + 'weknora-api': 120, + 'local-agent': 300, +} +_ALIASES = { + # legacy runner: (UI/hyphen key, alternate key, default when both missing) + 'coze-api': [('auto-save-history', 'auto_save_history')], + 'dashscope-app-api': [('references-quote', 'references_quote')], + 'langflow-api': [('input-type', 'input_type'), ('output-type', 'output_type')], +} _ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'} _REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'} _REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'} @@ -240,38 +288,6 @@ def _attachments(value): ) -def _validate_preferences(result, preferences, name): - if preferences is None: - return - if type(preferences) is not dict or not _strict_json(preferences): - _block(result, 'invalid_extension_preferences', 'extensions_preferences') - return - for field in ( - 'enable_all_plugins', - 'enable_all_mcp_servers', - 'enable_all_skills', - 'mcp_resource_agent_read_enabled', - ): - if field in preferences and type(preferences[field]) is not bool: - _block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') - for field in ('mcp_servers', 'skills'): - if field in preferences and not _names(preferences[field]): - _block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') - if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']): - _block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources') - plugins = preferences.get('plugins', []) - if type(plugins) is not list or not all( - type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins - ): - _block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins') - elif preferences.get('enable_all_plugins', True) is False and not any( - item['author'] == 'langbot-team' and item['name'] == name for item in plugins - ): - # The immutable result contract contains no extension-preference patch. - # Require explicit binding instead of silently flipping allow-all. - _block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins') - - def _warn(result, code, field): diagnostic = {'code': code, 'field': field} if diagnostic not in result['warnings']: @@ -321,42 +337,138 @@ def _valid_prompt(prompt): return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt) -def _validate_local(result, section): +_TRUE = {'true', '1', 'yes', 'on'} +_FALSE = {'false', '0', 'no', 'off'} +_CUSTOM_RUNNER_NAME = re.compile(r'[A-Za-z0-9_.\-]{1,64}') +_DEFAULT_BOX_TEMPLATE = '{launcher_type}_{launcher_id}' +_HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+") + + +def _coerce(kind, value): + """Best-effort, lossless coercion. Return (ok, value).""" + if kind == 'bool': + if type(value) is bool: + return True, value + if type(value) is int and value in (0, 1): + return True, bool(value) + if type(value) is str and value.strip().lower() in _TRUE: + return True, True + if type(value) is str and value.strip().lower() in _FALSE: + return True, False + return False, None + if kind in ('int', 'number'): + if type(value) is int: + return True, value + number = value + if type(value) is str and value.strip(): + text = value.strip() + try: + return True, int(text) + except ValueError: + pass + try: + number = float(text) + except ValueError: + return False, None + if type(number) is not float or not math.isfinite(number): + return False, None + if number.is_integer(): + return True, int(number) + return (kind == 'number'), (number if kind == 'number' else None) + if kind == 'names': + if type(value) is str: + return True, ([value.strip()] if value.strip() else []) + if type(value) is list: + return True, [v.strip() for v in value if type(v) is str and v.strip()] + return False, None + if kind == 'attachments': + if type(value) is list: + return True, [v for v in value if type(v) is dict and ('enabled' not in v or type(v['enabled']) is bool)] + return False, None + if type(value) is str: + return True, value + if type(value) in (int, float): + return True, str(value) + return False, None + + +def _kind(legacy, field): + if field in _BOOLEANS: + return 'bool' + if field in _INTEGERS: + return 'int' + if field == 'timeout': + return 'int' if legacy in _INT_TIMEOUT else 'number' + if field in _NAME_LISTS: + return 'names' + if field == 'mcp-resources': + return 'attachments' + return 'str' + + +def _normalize_local(result, section): + """Repair the LocalAgent model/prompt/template; never block on the model.""" prefix = 'ai.local-agent' model = section.get('model') if type(model) is dict: if set(model) - {'primary', 'fallbacks', 'reasoning'}: - _block(result, 'unknown_field', f'{prefix}.model') - if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])): - _block(result, 'invalid_type', f'{prefix}.model') + _warn(result, 'migration.unknown_field_dropped', f'{prefix}.model') + primary = model.get('primary') + if type(primary) is not str: + _warn(result, 'migration.missing_default_filled', f'{prefix}.model') + primary = '' + raw_fallbacks = model.get('fallbacks', []) + ok, fallbacks = _coerce('names', raw_fallbacks) + if not ok or fallbacks != raw_fallbacks: + _warn(result, 'migration.value_coerced', f'{prefix}.model.fallbacks') reasoning = model.get('reasoning', {}) - if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()): - _block(result, 'invalid_type', f'{prefix}.model.reasoning') - elif any(value not in _REASONING_LEVELS for value in reasoning.values()): - _block(result, 'local.reasoning_value', f'{prefix}.model') - elif reasoning: + if type(reasoning) is not dict: + _warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning') + reasoning = {} + valid = {k: v for k, v in reasoning.items() if type(v) is str and v in _REASONING_LEVELS} + if len(valid) != len(reasoning): + _warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning') + if valid: _warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning') - elif type(model) is not str or not model: - _block(result, 'invalid_type', f'{prefix}.model') - if not _valid_prompt(section.get('prompt')): - _block(result, 'local.prompt_shape', f'{prefix}.prompt') + section['model'] = {'primary': primary, 'fallbacks': fallbacks or [], 'reasoning': valid} + elif type(model) is str and model.strip(): + section['model'] = {'primary': model, 'fallbacks': [], 'reasoning': {}} + else: + # No usable model: migrate anyway; the user picks one after migration. + _warn(result, 'migration.missing_default_filled', f'{prefix}.model') + section['model'] = {'primary': '', 'fallbacks': [], 'reasoning': {}} + prompt = section.get('prompt') + if prompt is None: + section['prompt'] = copy.deepcopy(_LOCAL_PROMPT) + _warn(result, 'migration.missing_default_filled', f'{prefix}.prompt') + elif type(prompt) is str: + section['prompt'] = [{'role': 'system', 'content': prompt}] + _warn(result, 'local.prompt_normalized', f'{prefix}.prompt') + elif not _valid_prompt(prompt): + kept = [] + if type(prompt) is list: + for item in prompt: + if type(item) is dict and type(item.get('role')) is str and type(item.get('content')) is str: + kept.append({'role': item['role'], 'content': item['content']}) + section['prompt'] = kept if kept and _valid_prompt(kept) else copy.deepcopy(_LOCAL_PROMPT) + _warn(result, 'local.prompt_normalized', f'{prefix}.prompt') _warn(result, 'local.context_defaults', f'{prefix}.max-round') _warn(result, 'local.serial_tools_preserved', f'{prefix}.tools') _warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases') - template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}') - if type(template) is not str or not template.strip(): - _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') - else: + template = section.get('box-session-id-template', _DEFAULT_BOX_TEMPLATE) + valid_template = type(template) is str and bool(template.strip()) + if valid_template: try: - fields = list(Formatter().parse(template)) - if any( + valid_template = not any( name is not None and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion) - for _, name, spec, conversion in fields - ): - raise ValueError('Only named interpolation variables are supported') + for _, name, spec, conversion in Formatter().parse(template) + ) except ValueError: - _block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template') + valid_template = False + if not valid_template: + section['box-session-id-template'] = _DEFAULT_BOX_TEMPLATE + _warn(result, 'local.box_template_defaulted', f'{prefix}.box-session-id-template') _warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template') @@ -380,36 +492,28 @@ def _parse_tweaks(raw): return decoded -def _validate_external(result, legacy, section): +def _default_for(legacy, field): + for source in (_FILL.get(legacy, {}), _DEFAULTS[legacy]): + if field in source: + return True, copy.deepcopy(source[field]) + return False, None + + +def _normalize_external(result, legacy, section): prefix = f'ai.{legacy}' app_types = { 'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'), 'dashscope-app-api': ('agent', 'workflow'), 'weknora-api': ('chat', 'agent'), } - required_strings = { - 'dify-service-api': ('api-key', 'base-url'), - 'coze-api': ('api-key', 'bot-id'), - 'dashscope-app-api': ('api-key', 'app-id'), - 'n8n-service-api': ('webhook-url',), - 'langflow-api': ('base-url', 'api-key', 'flow-id'), - 'tbox-app-api': ('api-key', 'app-id'), - 'weknora-api': ('api-key', 'base-url'), - } - for field in required_strings.get(legacy, ()): + for field in _CRITICAL[legacy]: value = section.get(field) - if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())): + if type(value) is str and not value.strip(): _block(result, 'invalid_value', f'{prefix}.{field}') - if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]: - _block(result, 'invalid_value', f'{prefix}.app-type') + if legacy in app_types and section.get('app-type') not in app_types[legacy]: + section['app-type'] = _FILL[legacy]['app-type'] + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.app-type') if legacy == 'coze-api': - actual, alias = section.get('auto_save_history'), section.get('auto-save-history') - if type(actual) is bool and type(alias) is bool and actual != alias: - _block(result, 'coze.history_alias', f'{prefix}.auto_save_history') - if 'auto_save_history' in section or 'auto-save-history' in section: - _warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history') - if actual is None and alias is None: - _warn(result, 'migration.null_default', f'{prefix}.auto_save_history') base = section.get('api-base') try: parsed = urlsplit(base) if type(base) is str else None @@ -428,48 +532,35 @@ def _validate_external(result, legacy, section): except ValueError: _block(result, 'coze.custom_endpoint', f'{prefix}.api-base') _warn(result, 'coze.persistent_history', prefix) - elif legacy == 'dashscope-app-api': - if 'references-quote' in section: - if 'references_quote' in section and section['references_quote'] != section['references-quote']: - _block(result, 'dashscope.references_alias', f'{prefix}.references_quote') - else: - _warn(result, 'migration.alias_repaired', f'{prefix}.references-quote') elif legacy == 'langflow-api': - for axis in ('input', 'output'): - actual, ui = f'{axis}_type', f'{axis}-type' - if ui in section and section[ui] != section.get(actual, 'chat'): - _block(result, 'langflow.io_alias', f'{prefix}.{actual}') - if actual in section: - _warn(result, 'migration.alias_repaired', f'{prefix}.{actual}') - try: - _parse_tweaks(section.get('tweaks')) - except (ValueError, RecursionError): - _block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks') - if section.get('tweaks') is None or ( - type(section.get('tweaks')) is str - and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null') - ): + raw = section.get('tweaks') + if raw is None or (type(raw) is str and (not raw.strip() or raw.strip() == 'null')): _warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks') + try: + section['tweaks'] = copy.deepcopy(_parse_tweaks(raw)) + except (ValueError, RecursionError): + section['tweaks'] = {} + _warn(result, 'langflow.tweaks_invalid_defaulted', f'{prefix}.tweaks') _warn(result, 'langflow.persistent_history', prefix) elif legacy == 'dify-service-api': _warn(result, 'dify.timeout_default', f'{prefix}.timeout') elif legacy == 'n8n-service-api': - mode = section.get('auth-type', 'none') auth_fields = { 'none': (), 'basic': ('basic-username', 'basic-password'), 'jwt': ('jwt-secret',), 'header': ('header-name', 'header-value'), } - if type(mode) is not str or mode not in auth_fields: - _block(result, 'invalid_value', f'{prefix}.auth-type') - else: - for field in auth_fields[mode]: - if field not in section: - _block(result, 'missing_field', f'{prefix}.{field}') - response = section.get('response-handling', 'reply') - if response not in ('reply', 'ignore'): - _block(result, 'invalid_value', f'{prefix}.response-handling') + mode = section.get('auth-type', 'none') + if mode not in auth_fields: + mode = section['auth-type'] = 'none' + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.auth-type') + for field in auth_fields[mode]: + if field not in section: + _block(result, 'missing_field', f'{prefix}.{field}') + if section.get('response-handling', 'reply') not in ('reply', 'ignore'): + section['response-handling'] = 'reply' + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.response-handling') if mode == 'basic': for field in ('basic-username', 'basic-password'): value = section.get(field) @@ -481,75 +572,133 @@ def _validate_external(result, legacy, section): except (UnicodeEncodeError, ValueError): _block(result, 'n8n.basic_encoding', f'{prefix}.{field}') if mode == 'header' and type(section.get('header-name')) is str: - if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']): + if not _HEADER_NAME.fullmatch(section['header-name']): _block(result, 'n8n.header_name', f'{prefix}.header-name') _warn(result, 'n8n.session_ids_reset', prefix) - elif legacy == 'weknora-api': - knowledge = section.get('knowledge-base-ids') - if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge): - _block(result, 'invalid_value', f'{prefix}.knowledge-base-ids') - if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'): - timeout = section.get('timeout', 120) - if type(timeout) in (int, float) and timeout <= 0: - _block(result, 'invalid_value', f'{prefix}.timeout') - - -def _validate_section(result, legacy, section): - prefix = f'ai.{legacy}' - if set(section) - _FIELDS[legacy]: - _block(result, 'unknown_field', prefix) - for field in _REQUIRED[legacy]: - if field not in section: - _block(result, 'missing_field', f'{prefix}.{field}') - # Iterate only known keys, never put an arbitrary saved key in diagnostics. - for field in sorted(_FIELDS[legacy] & section.keys()): - value = section[field] - if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'): - continue - if value is None and ( - (legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history')) - or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids')) - ): - continue - if field in _BOOLEANS: - valid = type(value) is bool - elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'): - valid = type(value) is int - elif field == 'timeout': - valid = type(value) in (int, float) - elif field in _NAME_LISTS: - valid = _names(value) - elif field == 'mcp-resources': - valid = _attachments(value) - else: - valid = type(value) is str - if not valid: - _block(result, 'invalid_type', f'{prefix}.{field}') - if legacy == 'local-agent': - _validate_local(result, section) - else: - _validate_external(result, legacy, section) - if legacy == 'deerflow-api': + elif legacy == 'deerflow-api': base = section.get('api-base') if type(base) is str and not base.strip().startswith(('http://', 'https://')): _block(result, 'invalid_value', f'{prefix}.api-base') -def _validate_output(result, config, legacy): +def _normalize_section(result, legacy, section): + """Return a repaired copy of a legacy runner section (value-free diagnostics).""" + prefix = f'ai.{legacy}' + critical = _CRITICAL[legacy] + if set(section) - _FIELDS[legacy]: + _warn(result, 'migration.unknown_field_dropped', prefix) + # Iterate only known keys, never put an arbitrary saved key in diagnostics. + known = {k: copy.deepcopy(section[k]) for k in sorted(_FIELDS[legacy] & section.keys())} + for preferred, other in _ALIASES.get(legacy, []): + # The UI (hyphen) key wins; dashscope's plugin schema keeps the underscore. + output = 'references_quote' if legacy == 'dashscope-app-api' else preferred + drop = other if output == preferred else preferred + if drop not in known: + continue + a, b = known.get(preferred), known.get(other) + if a is not None and b is not None and a != b: + _warn(result, 'migration.alias_conflict_resolved', f'{prefix}.{drop}') + else: + _warn(result, 'migration.alias_repaired', f'{prefix}.{drop}') + known.pop(drop) + known[output] = a if a is not None else b + if legacy == 'coze-api' and known.get('auto-save-history') is None: + known['auto-save-history'] = True + _warn(result, 'migration.null_default', f'{prefix}.auto_save_history') + for field in _REQUIRED[legacy]: + if field not in known and field in critical: + _block(result, 'missing_field', f'{prefix}.{field}') + for field in sorted(known): + value = known[field] + if field in ('model', 'prompt', 'tweaks'): + continue + if value is None and legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'): + continue + ok, coerced = (False, None) if value is None else _coerce(_kind(legacy, field), value) + if ok and field == 'timeout' and coerced <= 0: + ok = False + if ok: + if coerced != value or type(coerced) is not type(value): + _warn(result, 'migration.value_coerced', f'{prefix}.{field}') + known[field] = coerced + elif field in critical: + _block(result, 'invalid_type', f'{prefix}.{field}') + else: + known.pop(field) + _warn(result, 'migration.invalid_value_defaulted', f'{prefix}.{field}') + for field in _REQUIRED[legacy]: + if field not in known and field not in critical: + has_default, default = _default_for(legacy, field) + if has_default: + known[field] = default + if field != 'prompt': + _warn(result, 'migration.missing_default_filled', f'{prefix}.{field}') + if legacy == 'local-agent': + _normalize_local(result, known) + else: + _normalize_external(result, legacy, known) + return known + + +def _normalize_preferences(result, preferences, name): + """Ignore malformed preference fields; return a patch only to bind the runner.""" + if preferences is None: + return None + if type(preferences) is not dict or not _strict_json(preferences): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences') + return None + for field in ( + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + 'mcp_resource_agent_read_enabled', + ): + if field in preferences and type(preferences[field]) is not bool: + _warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') + for field in ('mcp_servers', 'skills'): + if field in preferences and not _names(preferences[field]): + _warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}') + if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources') + plugins = preferences.get('plugins', []) + + def valid(item): + return type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) + + kept = [item for item in plugins if valid(item)] if type(plugins) is list else [] + if type(plugins) is not list or len(kept) != len(plugins): + _warn(result, 'invalid_extension_preferences', 'extensions_preferences.plugins') + if preferences.get('enable_all_plugins', True) is False and not any( + item['author'] == 'langbot-team' and item['name'] == name for item in kept + ): + # Bind exactly the migrated runner plugin; never flip allow-all. + patched = copy.deepcopy(preferences) + patched['plugins'] = [*copy.deepcopy(kept), {'author': 'langbot-team', 'name': name}] + _warn(result, 'extensions.runner_added', 'extensions_preferences.plugins') + return patched + return None + + +def _normalize_output(result, config): output = config.get('output', {}) if type(output) is not dict: - _block(result, 'invalid_type', 'output') - return + _warn(result, 'migration.invalid_value_defaulted', 'output') + return False misc = output.get('misc', {}) if type(misc) is not dict: - _block(result, 'invalid_type', 'output.misc') - return + _warn(result, 'migration.invalid_value_defaulted', 'output.misc') + return False value = misc.get('remove-think', False) + ok, coerced = _coerce('bool', value) + if not ok: + _warn(result, 'migration.invalid_value_defaulted', 'output.misc.remove-think') + return False if type(value) is not bool: - _block(result, 'invalid_type', 'output.misc.remove-think') + _warn(result, 'migration.value_coerced', 'output.misc.remove-think') + return coerced -def _assemble(result, legacy, section, config, preferences): +def _assemble(result, legacy, section, remove_think, preferences): selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)} prefix = f'ai.{legacy}' if legacy in _IDENTITY_SOURCES: @@ -557,18 +706,18 @@ def _assemble(result, legacy, section, config, preferences): if legacy == 'local-agent': selected.pop('max-round', None) singular = selected.pop('knowledge-base', '') - if not selected['knowledge-bases'] and singular and singular != '__none__': + if not selected['knowledge-bases'] and type(singular) is str and singular and singular != '__none__': selected['knowledge-bases'] = [singular] _warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base') - model = selected['model'] - selected['model'] = ( - {'primary': model, 'fallbacks': [], 'reasoning': {}} - if type(model) is str - else {'fallbacks': [], 'reasoning': {}, **model} + preferences = preferences if type(preferences) is dict else {} + resources = preferences.get('mcp_resources', []) + read = preferences.get('mcp_resource_agent_read_enabled', True) + selected['mcp-resources'] = copy.deepcopy( + section.get('mcp-resources', resources if _attachments(resources) else []) + ) + selected['mcp-resource-agent-read-enabled'] = section.get( + 'mcp-resource-agent-read-enabled', read if type(read) is bool else True ) - preferences = preferences or {} - for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)): - selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default))) else: selected.update( { @@ -582,26 +731,15 @@ def _assemble(result, legacy, section, config, preferences): if legacy in _ASSET_RUNNERS: selected['langbot-assets-enabled'] = False if legacy in _REMOVE_THINK_RUNNERS: - selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False) + selected['remove-think'] = remove_think _warn(result, 'migration.output_policy_copied', 'output.misc.remove-think') if legacy == 'dify-service-api': selected['timeout'] = 30 - elif legacy == 'coze-api': - actual = selected.pop('auto_save_history', None) - selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history') - if selected['auto-save-history'] is None: - selected['auto-save-history'] = True - elif legacy == 'dashscope-app-api': - if 'references-quote' in selected: - selected['references_quote'] = selected.pop('references-quote') - elif legacy == 'langflow-api': - for axis in ('input', 'output'): - selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type']) - selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks'))) elif legacy == 'weknora-api': - selected.setdefault( - 'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning' - ) + if selected.get('agent-id') is None: + selected['agent-id'] = ( + 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning' + ) if selected['knowledge-base-ids'] is None: selected['knowledge-base-ids'] = [] _warn(result, 'weknora.session_title_changed', prefix) @@ -616,6 +754,7 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: 'target_runner_id': None, 'target_plugin': None, 'config': None, + 'extensions_preferences': None, 'changed_paths': [], 'blockers': [], 'warnings': [], @@ -630,26 +769,43 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: selection = ai.get('runner', {}) if type(selection) is not dict: return _block(result, 'invalid_type', 'ai.runner') - if 'id' in selection and 'runner' in selection: - return _block(result, 'mixed_runner_selection', 'ai.runner') + legacy = selection.get('runner') + known_legacy = type(legacy) is str and legacy in _TARGETS if 'id' in selection: current = selection['id'] - # A blank runner id paired with no legacy runner section means the saved - # pipeline simply has no runner selected. There is nothing to migrate and - # nothing to convert, so report not_legacy instead of failing the whole - # batch with a malformed-id blocker. - if type(current) is str and not current.strip() and not any(legacy in ai for legacy in _TARGETS): + valid_id = type(current) is str and bool(re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current)) + if 'runner' in selection: + # Prefer a valid current id; otherwise migrate the legacy selection. + if valid_id: + _warn(result, 'migration.mixed_runner_selection', 'ai.runner') + result['state'] = 'already_current' + return result + if not known_legacy: + return _block(result, 'mixed_runner_selection', 'ai.runner') + _warn(result, 'migration.mixed_runner_selection', 'ai.runner') + else: + # A blank runner id paired with no legacy runner section means the + # saved pipeline simply has no runner selected: nothing to migrate. + if type(current) is str and not current.strip() and not any(name in ai for name in _TARGETS): + return result + if not valid_id: + return _block(result, 'invalid_runner_id', 'ai.runner.id') + result['state'] = 'already_current' return result - if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current): - return _block(result, 'invalid_runner_id', 'ai.runner.id') - result['state'] = 'already_current' - return result if 'runner' not in selection: return result - legacy = selection['runner'] if type(legacy) is not str: return _block(result, 'invalid_type', 'ai.runner.runner') - if legacy not in _TARGETS: + if not known_legacy: + if not legacy.strip(): + return result + # 4.10 allowed in-tree custom runners (@runner_class). They have no + # plugin equivalent; report them without failing other pipelines. + result.update( + state='unsupported_runner', + legacy_runner=legacy if _CUSTOM_RUNNER_NAME.fullmatch(legacy) else None, + ) + _warn(result, 'legacy.custom_runner_unsupported', 'ai.runner.runner') return result name, version = _TARGETS[legacy] target = f'plugin:langbot-team/{name}/default' @@ -658,27 +814,27 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: target_runner_id=target, target_plugin={'author': 'langbot-team', 'name': name, 'version': version}, ) - if 'runner_config' in ai: - if type(ai['runner_config']) is not dict: - return _block(result, 'invalid_type', 'ai.runner_config') - if ai['runner_config']: - return _block(result, 'mixed_runner_config', 'ai.runner_config') - if 'expire-time' in selection: - expiry = selection['expire-time'] - if type(expiry) is not int or expiry < 0: - _block(result, 'invalid_expiry', 'ai.runner.expire-time') + if 'runner_config' in ai and (type(ai['runner_config']) is not dict or ai['runner_config']): + _warn(result, 'migration.runner_config_replaced', 'ai.runner_config') + expiry = selection.get('expire-time', 0) + ok, expiry_value = _coerce('int', expiry) + if not ok or expiry_value < 0: + expiry_value = 0 + _warn(result, 'migration.invalid_value_defaulted', 'ai.runner.expire-time') + elif type(expiry) is not int: + _warn(result, 'migration.value_coerced', 'ai.runner.expire-time') if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}: - _block(result, 'unknown_field', 'ai') - if set(selection) - {'runner', 'expire-time'}: - _block(result, 'unknown_field', 'ai.runner') + _warn(result, 'migration.unknown_section_archived', 'ai') + if set(selection) - {'runner', 'expire-time', 'id'}: + _warn(result, 'migration.unknown_field_dropped', 'ai.runner') if legacy not in ai: return _block(result, 'missing_field', f'ai.{legacy}') section = ai[legacy] if type(section) is not dict: return _block(result, 'invalid_type', f'ai.{legacy}') - _validate_section(result, legacy, section) - _validate_preferences(result, extensions_preferences, name) - _validate_output(result, config, legacy) + section = _normalize_section(result, legacy, section) + patched_preferences = _normalize_preferences(result, extensions_preferences, name) + remove_think = _normalize_output(result, config) _warn(result, 'external.state_validation_required', 'ai.runner') _warn(result, 'migration.history_reset', 'ai.runner') _warn(result, 'migration.new_defaults', f'ai.{legacy}') @@ -689,22 +845,22 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: _warn(result, 'migration.identity_preserved', f'ai.{legacy}') if result['blockers']: return result + selected = _assemble(result, legacy, section, remove_think, extensions_preferences) candidate = copy.deepcopy(config) - candidate['ai']['runner'].pop('runner') - candidate['ai']['runner']['id'] = target - selected = _assemble(result, legacy, section, config, extensions_preferences) - candidate['ai']['runner_config'] = {target: selected} - # The durable transaction stores the full source, including inactive legacy - # credentials. The active configuration must be canonical so the guarded - # editor never needs to interpret mixed legacy/current containers. - archived_paths = [] - for old_runner in _TARGETS: - if old_runner in candidate['ai']: - candidate['ai'].pop(old_runner) - archived_paths.append(f'ai.{old_runner}') + # The durable snapshot stores the full source, including inactive legacy + # credentials and unknown leftovers. The active configuration is canonical. + candidate['ai'] = { + 'runner': {'id': target, 'expire-time': expiry_value}, + 'runner_config': {target: selected}, + } + archived_paths = [f'ai.{old}' for old in _TARGETS if old in ai] + changed = ['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths] + if patched_preferences is not None: + changed.append('extensions_preferences.plugins') result.update( state='ready', config=candidate, - changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths], + extensions_preferences=patched_preferences, + changed_paths=changed, ) return result diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index 15ae38f47..25b58ec8b 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -18,6 +18,103 @@ if typing.TYPE_CHECKING: from .pool import get_query_execution_context +async def resolve_processor_name( + ap: app.Application, + workspace_uuid: str, + processor_type: str, + processor_id: str | None, + runner_id: str = '', +) -> str: + """Display name of the processor kind that handled a run. + + Monitoring attributed every call to a Pipeline, so an Agent or a plugin event + processor recorded "Unknown" for the entity that actually ran. Each kind is + resolved from the table that owns it, falling back to the identity the + binding carried. + """ + import sqlalchemy + + from ..entity.persistence import agent as persistence_agent + from ..entity.persistence import pipeline as persistence_pipeline + + identifier = str(processor_id or '').strip() + if identifier: + try: + model = persistence_pipeline.LegacyPipeline if processor_type == 'pipeline' else persistence_agent.Agent + result = await ap.persistence_mgr.execute_async( + sqlalchemy.select(model.name).where( + sqlalchemy.and_( + model.uuid == identifier, + model.workspace_uuid == workspace_uuid, + ) + ) + ) + name = str(result.scalar() or '').strip() + if name: + return name + except Exception as e: + ap.logger.warning(f'Failed to resolve monitoring processor name: {e}') + # A deleted Agent or an unregistered plugin processor still names the + # component that ran, never a bare "Unknown". + return identifier or runner_id or processor_type + + +async def prepare_monitoring_identity( + ap: app.Application, + query: pipeline_query.Query, + *, + processor_type: str, + processor_id: str | None, + runner_id: str = '', + execution_record_id: str = '', +) -> None: + """Pin the monitoring identity of a runner-owned execution. + + A Pipeline pins these variables when it starts a query; a Runner started by + an Agent or a plugin event processor has to pin them itself, otherwise every + LLM/tool call it makes is attributed to "Unknown". An enclosing Pipeline + already named the run, so its label is never overwritten. + + ``_monitoring_message_id`` is the execution record the calls belong to: the + Pipeline's message, or the Agent run's id. Both are the id the executions + list shows, which is what lets a call jump back to its record. + """ + try: + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + return + if variables.get('_monitoring_pipeline_name'): + return + + bot_name = 'WebChat' + bot_uuid = str(getattr(query, 'bot_uuid', '') or '') + workspace_uuid = str(getattr(query, 'workspace_uuid', '') or '') + if bot_uuid and workspace_uuid: + try: + bot = await ap.bot_service.get_bot( + workspace_uuid, + bot_uuid, + include_secret=False, + ) + if bot: + bot_name = bot.get('name', bot_name) + except Exception as e: + ap.logger.warning(f'Failed to resolve monitoring bot name: {e}') + + variables['_monitoring_bot_name'] = bot_name + variables['_monitoring_pipeline_name'] = await resolve_processor_name( + ap, + workspace_uuid, + processor_type, + processor_id, + runner_id, + ) + if execution_record_id: + variables['_monitoring_message_id'] = execution_record_id + except Exception as e: + ap.logger.error(f'Failed to prepare monitoring identity: {e}') + + class MonitoringHelper: """Helper class for monitoring operations""" @@ -46,12 +143,9 @@ class MonitoringHelper: sender_name = query.message_event.sender.member_name # Try to record message - # Use JSON serialization to preserve message chain structure (including image URLs, etc.) + # Let persistence externalize images before serializing the message chain. if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'): - chain_dump = query.message_chain.model_dump() - if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'): - chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) - message_content = json.dumps(chain_dump, ensure_ascii=False) + message_content = query.message_chain.model_dump() else: message_content = str(query) @@ -160,10 +254,10 @@ class MonitoringHelper: # Extract response content from resp_message_chain if hasattr(query, 'resp_message_chain') and query.resp_message_chain: - # Serialize the last response message chain + # Preserve the structure until persistence has externalized images. last_resp = query.resp_message_chain[-1] if hasattr(last_resp, 'model_dump'): - message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False) + message_content = last_resp.model_dump() else: message_content = str(last_resp) elif hasattr(query, 'resp_messages') and query.resp_messages: @@ -171,10 +265,7 @@ class MonitoringHelper: if hasattr(last_resp, 'get_content_platform_message_chain'): chain = last_resp.get_content_platform_message_chain() if hasattr(chain, 'model_dump'): - chain_dump = chain.model_dump() - if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'): - chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) - message_content = json.dumps(chain_dump, ensure_ascii=False) + message_content = chain.model_dump() else: message_content = str(chain) else: @@ -199,6 +290,7 @@ class MonitoringHelper: user_name=sender_name, runner_name=runner_name, role='assistant', + parent_message_id=(query.variables or {}).get('_monitoring_message_id'), ) except Exception as e: ap.logger.error(f'Failed to record query response: {e}') @@ -235,6 +327,7 @@ class MonitoringHelper: pipeline_id=pipeline_id, pipeline_name=pipeline_name, message_content=f'Error: {str(error)}', + role='assistant', session_id=session_id, status='error', level='error', @@ -281,6 +374,7 @@ class MonitoringHelper: cost: float | None = None, error_message: str | None = None, message_id: str | None = None, + details: dict | None = None, ): """Record LLM call""" try: @@ -301,6 +395,7 @@ class MonitoringHelper: cost=cost, error_message=error_message, message_id=message_id, + details=details, ) except Exception as e: ap.logger.error(f'Failed to record LLM call: {e}') diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index c98b94c8a..fc7aaaf31 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -20,6 +20,8 @@ from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType from ..workspace.errors import WorkspaceError, WorkspaceInvariantError from .config_coercion import coerce_pipeline_config from .pool import get_query_execution_context +from ..i18n import language_scope +from ..i18n.workspace import get_workspace_language from ..agent.runner.config_resolver import RunnerConfigResolver from .extension_preferences import normalize_extension_preferences @@ -194,6 +196,19 @@ class RuntimePipeline: 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 + # Telemetry: the bindings this lane runs with. None means "all enabled", + # which the *_all flags make explicit in the chain record. + try: + from ..telemetry import resources as telemetry_resources + + telemetry_resources.note_pipeline( + plugins=self.bound_plugins, + plugins_all=self.enable_all_plugins, + mcp_servers=self.bound_mcp_servers, + mcp_all=self.enable_all_mcp_servers, + ) + except Exception: + pass # Record query start for monitoring try: @@ -370,7 +385,43 @@ class RuntimePipeline: i += 1 + @staticmethod + def _synthetic_origin(query: pipeline_query.Query) -> str: + """Origin of a lane that starts without an inbound platform event.""" + try: + variables = getattr(query, 'variables', None) + declared = str(variables.get('_telemetry_origin') or '').strip() if isinstance(variables, dict) else '' + except Exception: + declared = '' + if declared in ('webui', 'api'): + return declared + adapter = getattr(query, 'adapter', None) + if adapter is not None and adapter.__class__.__name__ == 'WebSocketAdapter': + # WebChat and the CLI diagnostic surface both speak through the + # WebSocket proxy adapter; the CLI marks itself in its variables. + try: + variables = getattr(query, 'variables', None) + if isinstance(variables, dict) and '_cli_run_status' in variables: + return 'api' + except Exception: + pass + return 'webui' + return 'api' + + @staticmethod + def _synthetic_event_type(query: pipeline_query.Query) -> str: + """Event type of the virtual inbound event a synthetic lane stands for.""" + event = getattr(query, 'message_event', None) + event_type = str(getattr(event, 'type', None) or '').strip() + return event_type[:160] or 'message.received' + async def process_query(self, query: pipeline_query.Query): + workspace_uuid = getattr(query, 'workspace_uuid', None) or getattr(self, 'workspace_uuid', '') + with language_scope(await get_workspace_language(self.ap, workspace_uuid)): + return await self._process_query_with_language(query) + + async def _process_query_with_language(self, query: pipeline_query.Query): + from ..telemetry import trace as trace_mod from ..telemetry.execution import ingress from ..telemetry.execution import record as record_execution from ..telemetry.platform import processing_mode @@ -379,11 +430,23 @@ class RuntimePipeline: token = processing_mode.set('pipeline') # The Workspace-scoped opaque query uuid is the execution identity Space # shows for this lane; it is a stable UUID for every pooled query. - execution_id = str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id) + ingress_event_id = (query.variables or {}).get('_ingress_event_id') + execution_id = ingress_event_id or str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id) + # A lane with no inbound platform event in flight (WebChat, WebUI debug, + # HTTP pipeline run) synthesizes its virtual inbound event here, so every + # execution starts at an event boundary and the Runner is never the trace + # origin. A lane running under a platform route nests inside that ingress. + synthesizing = trace_mod.current() is None and not ingress_event_id try: - # Callers without a platform event (Webchat, HTTP API) still get one - # trace for the whole Pipeline lane; nested calls reuse the trace. - with ingress(self.ap, 'pipeline_done', getattr(self, 'execution_context', None), execution_id=execution_id): + with ingress( + self.ap, + 'pipeline_done', + getattr(self, 'execution_context', None), + execution_id=execution_id, + debug=synthesizing, + origin=self._synthetic_origin(query) if synthesizing else 'platform', + synthetic_event=self._synthetic_event_type(query) if synthesizing else '', + ): # The lane is its own workflow step: acknowledgements and replies it # sends directly, and the runner it drives, hang under this node. # When the lane runs under a platform route the node nests there. @@ -391,7 +454,12 @@ class RuntimePipeline: lane_outcome = 'success' lane_error = '' try: - return await self._process_query(query) + from ..platform.processing_indicator import processing_indicator + + async with processing_indicator( + query.adapter, query.launcher_type.value.lower(), query.launcher_id + ): + return await self._process_query(query) except asyncio.CancelledError: lane_outcome = 'cancelled' lane_error = 'cancelled' @@ -576,14 +644,8 @@ class RuntimePipeline: state = current_trace() # The runner orchestrator already lands its own failed ``runner/execute`` # node when the break happens inside a runner; in that case only carry the - # reason over instead of repeating the node on the trace. - already_recorded = bool( - state is not None - and any( - stage.get('family') == 'runner' and stage.get('outcome') not in ('success', 'skipped') - for stage in state.stages - ) - ) + # reason over instead of repeating the node on the chain. + already_recorded = bool(state is not None and state.runner_failed) if not already_recorded: # The lane failure is its own workflow step, hanging under whatever # step routed into it (or a root when nothing did). diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 67db3b1a4..37c5d28b8 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -108,6 +108,12 @@ class PreProcessor(stage.PipelineStage): ) catalog = ResourcePolicyProjector.filter_tools(catalog, policy) ToolManager.bind_query_tool_sources(query, catalog) + try: + from ...telemetry import resources as telemetry_resources + + telemetry_resources.note_pipeline(tools=catalog, tools_all=policy.allow_all_tools) + except Exception: + pass return ToolManager.tools_from_catalog(catalog) def _runner_accepts_multimodal_input(self, descriptor: RunnerDescriptor | None) -> bool: @@ -343,8 +349,10 @@ class PreProcessor(stage.PipelineStage): plain_text += me.text elif isinstance(me, platform_message.Image): if keep_image_inputs: - if me.base64 is not None: + if me.base64: content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) + elif me.url: + content_list.append(provider_message.ContentElement.from_image_url(me.url)) else: content_list.append(provider_message.ContentElement.from_text('[Image]')) plain_text += '[Image]' @@ -365,8 +373,10 @@ class PreProcessor(stage.PipelineStage): content_list.append(provider_message.ContentElement.from_text(msg.text)) elif isinstance(msg, platform_message.Image): if keep_image_inputs: - if msg.base64 is not None: + if msg.base64: content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) + elif msg.url: + content_list.append(provider_message.ContentElement.from_image_url(msg.url)) else: content_list.append(provider_message.ContentElement.from_text('[Image]')) plain_text += '[Image]' @@ -393,6 +403,18 @@ class PreProcessor(stage.PipelineStage): descriptor, runner_config, ) + # Telemetry: the knowledge bases this lane may retrieve from, plus the + # schema-declared selector fields that selected them. + try: + from ...telemetry import resources as telemetry_resources + + telemetry_resources.note_pipeline( + knowledge_bases=query.variables['_knowledge_base_uuids'], + runner_id=descriptor.id if descriptor is not None else '', + selectors=config_schema.extract_selected_resources(descriptor, runner_config), + ) + except Exception: + pass # Emit PromptPreProcessing before the runner receives the query. @@ -427,4 +449,10 @@ class PreProcessor(stage.PipelineStage): bound_skills = extensions_prefs['skills'] query.variables['_pipeline_bound_skills'] = bound_skills + try: + from ...telemetry import resources as telemetry_resources + + telemetry_resources.note_pipeline(skills=bound_skills) + except Exception: + pass return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index 2cd681fa1..5226d56b0 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -1,11 +1,14 @@ from __future__ import annotations +from ....i18n import pipeline_notice + import uuid import typing import traceback from .. import handler +from ..stream_results import coalesce_stream_results from ... import entities from ... import plugin_diagnostics @@ -132,7 +135,10 @@ class ChatMessageHandler(handler.MessageHandler): # Use AgentRunOrchestrator to run the agent # This replaces direct runner lookup and PluginRunnerWrapper - async for result in self.ap.agent_run_orchestrator.run_from_query(query): + results = self.ap.agent_run_orchestrator.run_from_query(query) + if is_stream: + results = coalesce_stream_results(results) + async for result in results: has_result = True self._check_response_size(result) @@ -149,6 +155,11 @@ class ChatMessageHandler(handler.MessageHandler): ) result.attachments = attachments + if is_stream and result.tool_calls: + # A model round ending in tool calls does not end the + # reply. Keep the native stream open for the answer. + result = result.model_copy(update={'is_final': False}) + result.resp_message_id = str(resp_message_id) # For streaming mode, pop previous response before adding new chunk @@ -192,6 +203,11 @@ class ChatMessageHandler(handler.MessageHandler): if is_stream: yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) + if result.is_final and not result.tool_calls: + # Native adapters release their card on completion. + # A distinct later message needs a fresh card identity. + is_create_card = False + resp_message_id = uuid.uuid4() # Log final summary after streaming completes if is_stream: @@ -224,14 +240,20 @@ class ChatMessageHandler(handler.MessageHandler): # Handle specific runner errors with appropriate messages if isinstance(e, RunnerNotFoundError): - user_notice = f'Agent runner not found: {e.runner_id}' + user_notice = pipeline_notice( + query, 'runner-not-found-hint', 'Agent runner not found: {runner}', runner=e.runner_id + ) elif isinstance(e, RunnerNotAuthorizedError): - user_notice = 'Agent runner not authorized for this pipeline' + user_notice = pipeline_notice( + query, 'runner-not-authorized-hint', 'Agent runner not authorized for this pipeline.' + ) elif isinstance(e, RunnerExecutionError): if e.retryable: - user_notice = 'Agent runner temporarily unavailable. Please try again.' + user_notice = pipeline_notice( + query, 'runner-unavailable-hint', 'Agent runner temporarily unavailable. Please try again.' + ) else: - user_notice = 'Agent runner execution failed.' + user_notice = pipeline_notice(query, 'failure-hint', 'Agent runner execution failed.') else: # Use existing exception handling exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint') @@ -239,7 +261,7 @@ class ChatMessageHandler(handler.MessageHandler): if exception_handling == 'show-error': user_notice = f'{e}' elif exception_handling == 'show-hint': - user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.') + user_notice = pipeline_notice(query, 'failure-hint', 'Request failed.') else: # hide user_notice = None diff --git a/src/langbot/pkg/pipeline/process/stream_results.py b/src/langbot/pkg/pipeline/process/stream_results.py new file mode 100644 index 000000000..35e0dd09a --- /dev/null +++ b/src/langbot/pkg/pipeline/process/stream_results.py @@ -0,0 +1,55 @@ +"""Coalesce a runner's terminal delta and completed snapshot before delivery.""" + +from collections.abc import AsyncIterable, AsyncIterator + +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + +async def coalesce_stream_results( + results: AsyncIterable[Message | MessageChunk], +) -> AsyncIterator[Message | MessageChunk]: + pending: MessageChunk | None = None + snapshot = '' + async for result in results: + if pending is not None: + # Completion snapshots can add attachments and usage to the final + # delta. Deliver that richer result once, before closing the card. + content = pending.all_content if pending.all_content is not None else pending.content + # Multi-round runners stream a cumulative transcript, but complete + # with only the last model round. Keep the transcript on the same + # card while taking attachments and metadata from the completion. + final_round = ( + isinstance(content, str) + and isinstance(result.content, str) + and bool(result.content) + and content.endswith(result.content) + ) + same_message = ( + isinstance(result, Message) + and result.role == pending.role + and (result.tool_calls or []) == (pending.tool_calls or []) + and (not content or result.content == content or final_round) + ) + if same_message and final_round and content != result.content: + result = result.model_copy(update={'content': content}) + elif not same_message: + yield pending + pending = None + snapshot = '' + + if isinstance(result, MessageChunk) and isinstance(result.content, (str, type(None))): + # Runner output is already cumulative (unlike raw provider deltas). + # Explicit all_content also supports runners forwarding raw deltas. + # Never guess delta semantics from a missing all_content field. + snapshot = result.all_content if result.all_content is not None else (result.content or snapshot) + result = result.model_copy(update={'all_content': snapshot}) + elif isinstance(result, Message): + snapshot = '' + + if isinstance(result, MessageChunk) and result.is_final: + pending = result + else: + yield result + + if pending is not None: + yield pending diff --git a/src/langbot/pkg/pipeline/ratelimit/ratelimit.py b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py index cab62b8d5..8ee1bb639 100644 --- a/src/langbot/pkg/pipeline/ratelimit/ratelimit.py +++ b/src/langbot/pkg/pipeline/ratelimit/ratelimit.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ...i18n import pipeline_notice + import typing from .. import entities, stage @@ -62,7 +64,7 @@ class RateLimit(stage.PipelineStage): result_type=entities.ResultType.INTERRUPT, new_query=query, console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息', - user_notice='请求数超过限速器设定值,已丢弃本消息。', + user_notice=pipeline_notice(query, 'rate-limit-hint', 'Too many requests. Please try again later.'), ) elif stage_inst_name == 'ReleaseRateLimitOccupancy': await self.algo.release_access( diff --git a/src/langbot/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py index 6e7fc369e..3e8c16a96 100644 --- a/src/langbot/pkg/pipeline/resprule/resprule.py +++ b/src/langbot/pkg/pipeline/resprule/resprule.py @@ -37,8 +37,13 @@ class GroupRespondRuleCheckStage(stage.PipelineStage): if query.launcher_type.value != 'group': # 只处理群消息 return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - # 通过路由规则明确指定的流水线,跳过群响应规则检查 - if query.variables and query.variables.get('_routed_by_rule', False): + # Interaction submissions and routed platform form callbacks resume the + # original run without another mention or prefix. Ordinary routing only + # selects a pipeline and must still obey its group response rules. + if query.variables and ( + query.variables.get('_interaction_submission') + or (query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action')) + ): return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) rules = query.pipeline_config['trigger']['group-respond-rules'] diff --git a/src/langbot/pkg/pipeline/wrapper/wrapper.py b/src/langbot/pkg/pipeline/wrapper/wrapper.py index 9ea275e1d..2dcda3a05 100644 --- a/src/langbot/pkg/pipeline/wrapper/wrapper.py +++ b/src/langbot/pkg/pipeline/wrapper/wrapper.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ...i18n import pipeline_notice + import typing from .. import entities @@ -175,7 +177,9 @@ class ResponseWrapper(stage.PipelineStage): if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 function_names = [tc.function.name for tc in result.tool_calls] - reply_text = f'Call {".".join(function_names)}...' + reply_text = pipeline_notice( + query, 'tool-call-hint', 'Calling {tools}...', tools=', '.join(function_names) + ) # Only surface the tool-call notice when the pipeline option # is enabled. Emitting it unconditionally would become the diff --git a/src/langbot/pkg/platform/adapter_names.py b/src/langbot/pkg/platform/adapter_names.py index 448182af6..be5c1f56b 100644 --- a/src/langbot/pkg/platform/adapter_names.py +++ b/src/langbot/pkg/platform/adapter_names.py @@ -2,7 +2,6 @@ OMNI_ADAPTER_NAMES = frozenset( { - 'aiocqhttp', 'telegram', 'discord', 'qqofficial', @@ -20,6 +19,8 @@ OMNI_ADAPTER_NAMES = frozenset( def canonical_adapter_name(name: str) -> str: """Accept previous built-in IDs without renaming legacy or custom adapters.""" + if name in ('aiocqhttp-omni', 'aiocqhttp-eba'): + return 'onebot-v11' if name.endswith('-eba') and name[:-4] in OMNI_ADAPTER_NAMES: return f'{name[:-4]}-omni' return name diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py index 7d97cac81..6840d7131 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py @@ -2,6 +2,12 @@ from __future__ import annotations import asyncio import traceback +import hmac +from urllib.parse import urlsplit + +from websockets.asyncio.client import connect +from langbot.pkg.core.task_boundary import create_detached_task +from langbot.pkg.platform.adapters.aiocqhttp.transport import OneBotClient, OneBotConnection import typing import aiocqhttp @@ -20,7 +26,10 @@ from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedErro class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): - bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True) + bot: typing.Any = pydantic.Field(exclude=True) + _runtime: typing.Any = pydantic.PrivateAttr(default=None) + _stopped: asyncio.Event = pydantic.PrivateAttr(default_factory=asyncio.Event) + _connection_task: typing.Any = pydantic.PrivateAttr(default=None) message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter() event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter() @@ -36,24 +45,64 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - run_config = dict(config) + config = dict(config) + config.setdefault('connection_mode', 'reverse_ws') + if not isinstance(config.get('access-token'), str) or not config['access-token'].strip(): + raise ValueError('OneBot requires a non-empty access token') + if config['connection_mode'] not in ('reverse_ws', 'forward_ws'): + raise ValueError('Unsupported OneBot connection mode') + if config['connection_mode'] == 'forward_ws': + url = urlsplit(config.get('ws_url', '')) + if url.scheme not in ('ws', 'wss') or not url.hostname or url.username or url.password or url.fragment: + raise ValueError('OneBot ws_url must be a ws:// or wss:// endpoint without embedded credentials') + super().__init__(config=config, logger=logger, bot=OneBotClient(), bot_account_id='', listeners={}) - async def shutdown_trigger_placeholder(): - while True: - await asyncio.sleep(1) + def bind_runtime(self, runtime): + self._runtime = runtime - run_config['shutdown_trigger'] = shutdown_trigger_placeholder - access_token = run_config.pop('access-token', '') or None - bot = aiocqhttp.CQHttp(access_token=access_token) + async def _validate_connection(self): + if self._stopped.is_set() or self._runtime is None: + raise RuntimeError('OneBot runtime is inactive') + current = await self._runtime.ap.platform_mgr.resolve_public_bot(self._runtime.bot_entity.uuid) + if current is not self._runtime or not current.enable: + raise RuntimeError('OneBot runtime placement changed') - super().__init__( - config=run_config, - logger=logger, - bot=bot, - bot_account_id='', - listeners={}, + def authenticate(self, headers): + token = str(self.config.get('access-token', '')) + supplied = headers.get('Authorization', '') + return bool(token) and hmac.compare_digest(supplied.encode(), ('Bearer ' + token).encode()) + + async def serve_connection(self, send, receive, close, self_id=''): + await self._validate_connection() + if self.bot.connection is not None: + raise RuntimeError('OneBot already has an active connection') + expected = str(self.config.get('self_id', '') or '') + if expected and self_id and expected != self_id: + raise ValueError('OneBot account mismatch') + connection = OneBotConnection( + send, + receive, + close, + self._receive_event, + self._validate_connection, + self_id=expected or self_id, ) - self._register_native_handlers() + self.bot.connection = connection + self.bot_account_id = expected or self_id + self._lookup.clear() + task = create_detached_task(connection.run(), workspace_uuid=self._runtime.workspace_uuid) + self._connection_task = task + try: + await task + finally: + await connection.close() + if self.bot.connection is connection: + self.bot.connection = None + self._connection_task = None + self._lookup.clear() + + async def _receive_event(self, payload): + await self._handle_native_event(aiocqhttp.Event.from_payload(payload)) def get_supported_events(self) -> list[str]: return [ @@ -121,33 +170,51 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat self.listeners.pop(event_type, None) async def run_async(self): - await self.bot._server_app.run_task(**self.config) + if not self.config.get('access-token'): + raise ValueError('OneBot requires a non-empty access token') + if self.config['connection_mode'] == 'reverse_ws': + await self._stopped.wait() + return + delay = 1 + while not self._stopped.is_set(): + await self._validate_connection() + try: + async with connect( + self.config['ws_url'], + additional_headers={'Authorization': 'Bearer ' + self.config['access-token']}, + open_timeout=10, + close_timeout=5, + ping_interval=20, + ping_timeout=20, + max_size=8 * 1024 * 1024, + proxy=None, + ) as socket: + delay = 1 + await self.serve_connection(socket.send, socket.recv, socket.close) + except asyncio.CancelledError: + raise + except Exception as exc: + await self.logger.warning(f'OneBot connection lost ({type(exc).__name__}); reconnecting') + try: + await asyncio.wait_for(self._stopped.wait(), timeout=delay) + except TimeoutError: + pass + delay = min(delay * 2, 30) async def kill(self) -> bool: + self._stopped.set() + task = self._connection_task + if task is not None: + task.cancel() + await asyncio.gather(task, return_exceptions=True) self._lookup.clear() - return False - - def _register_native_handlers(self): - @self.bot.on_message() - async def on_message(event: aiocqhttp.Event): - await self._handle_native_event(event) - - @self.bot.on_notice() - async def on_notice(event: aiocqhttp.Event): - await self._handle_native_event(event) - - @self.bot.on_request() - async def on_request(event: aiocqhttp.Event): - await self._handle_native_event(event) - - @self.bot.on_websocket_connection - async def on_websocket_connection(event: aiocqhttp.Event): - self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id) - await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}') - await self._dispatch_native_event(event) + return True async def _handle_native_event(self, event: aiocqhttp.Event): self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id) + # Connection metadata belongs to the adapter, never business event routing. + if getattr(event, 'type', None) == 'meta_event': + return if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id: return try: diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py index d2c950c5f..295f4ef18 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py @@ -34,8 +34,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id) if event_type == 'request': return AiocqhttpEventConverter.request_to_eba(event) - if event_type == 'meta_event': - return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}') return None @staticmethod @@ -90,7 +88,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MessageReceivedEvent( type='message.received', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', message_id=getattr(event, 'message_id', ''), message_chain=message_chain, sender=sender, @@ -111,13 +109,19 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if notice_type in ('group_recall', 'friend_recall'): return platform_events.MessageDeletedEvent( type='message.deleted', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', message_id=getattr(event, 'message_id', ''), - operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)), + operator=AiocqhttpEventConverter.user( + getattr(event, 'user_id', None) + if notice_type == 'friend_recall' + else getattr(event, 'operator_id', None) + ), chat_type=platform_entities.ChatType.GROUP if notice_type == 'group_recall' else platform_entities.ChatType.PRIVATE, - chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')), + chat_id=getattr(event, 'group_id', '') + if notice_type == 'group_recall' + else getattr(event, 'user_id', ''), group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None, timestamp=float(getattr(event, 'time', 0) or 0), source_platform_object=event, @@ -129,7 +133,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=group, inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, timestamp=float(getattr(event, 'time', 0) or 0), @@ -137,7 +141,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MemberJoinedEvent( type='group.member_joined', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=group, member=user, inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None, @@ -151,7 +155,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event): return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=group, operator=operator, timestamp=float(getattr(event, 'time', 0) or 0), @@ -159,7 +163,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): ) return platform_events.MemberLeftEvent( type='group.member_left', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=group, member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'), @@ -175,7 +179,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent kwargs: dict[str, typing.Any] = { 'type': 'bot.muted' if duration > 0 else 'bot.unmuted', - 'adapter_name': 'aiocqhttp-omni', + 'adapter_name': 'onebot-v11', 'group': group, 'operator': operator, 'timestamp': float(getattr(event, 'time', 0) or 0), @@ -187,7 +191,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if duration > 0: return platform_events.MemberBannedEvent( type='group.member_banned', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=group, member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), operator=operator, @@ -198,7 +202,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if notice_type == 'friend_add': return platform_events.FriendAddedEvent( type='friend.added', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), timestamp=float(getattr(event, 'time', 0) or 0), source_platform_object=event, @@ -211,7 +215,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if request_type == 'friend': return platform_events.FriendRequestReceivedEvent( type='friend.request_received', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', request_id=getattr(event, 'flag', ''), user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), message=getattr(event, 'comment', None), @@ -221,7 +225,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite': return platform_events.BotInvitedToGroupEvent( type='bot.invited_to_group', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', group=AiocqhttpEventConverter.group_from_event(event), inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')), request_id=getattr(event, 'flag', ''), @@ -258,7 +262,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter): def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent: return platform_events.PlatformSpecificEvent( type='platform.specific', - adapter_name='aiocqhttp-omni', + adapter_name='onebot-v11', action=action, data={key: value for key, value in dict(event).items() if key not in {'message'}}, timestamp=float(getattr(event, 'time', 0) or 0), diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml index 6911881c9..3b5dec81b 100644 --- a/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: MessagePlatformAdapter metadata: - name: aiocqhttp-omni + name: onebot-v11 label: en_US: OneBot v11 zh_Hans: OneBot v11 @@ -15,48 +15,66 @@ metadata: spec: categories: + - popular - protocol help_links: zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat config: - - name: host - label: - en_US: Host - zh_Hans: 主机 - zh_Hant: 主機 - description: - en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0 - zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0 - zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0 - type: string + - name: connection_mode + label: {en_US: Connection mode, zh_Hans: 连接模式, zh_Hant: 連線模式, ja_JP: 接続方式} + type: select required: true - default: 0.0.0.0 - - name: port - label: - en_US: Port - zh_Hans: 端口 - zh_Hant: 連接埠 + default: reverse_ws + options: + - name: reverse_ws + label: {en_US: 'Reverse WS — protocol endpoint connects to LangBot', zh_Hans: '反向 WS — 协议端连接 LangBot', zh_Hant: '反向 WS — 協定端連接 LangBot', ja_JP: '逆方向 WS — プロトコル側から LangBot に接続'} + - name: forward_ws + label: {en_US: 'Forward WS — LangBot connects to protocol endpoint', zh_Hans: '正向 WS — LangBot 连接协议端', zh_Hant: '正向 WS — LangBot 連接協定端', ja_JP: '順方向 WS — LangBot からプロトコル側に接続'} description: - en_US: Reverse WebSocket listen port - zh_Hans: 反向 WebSocket 监听端口 - zh_Hant: 反向 WebSocket 監聽連接埠 - type: integer - required: true - default: 2280 - - name: access-token - label: - en_US: Access Token - zh_Hans: 访问令牌 - zh_Hant: 存取令牌 + en_US: Reverse WS uses LangBot's main service port. Forward WS requires a protocol endpoint reachable from LangBot. HTTP is not supported. + zh_Hans: 反向 WS 由协议端连接 LangBot 主服务端口,无需额外监听端口;正向 WS 由 LangBot 连接可访问的协议端。不支持 HTTP。 + zh_Hant: 反向 WS 由協定端連接 LangBot 主服務連接埠;正向 WS 由 LangBot 連接可存取的協定端。不支援 HTTP。 + ja_JP: 逆方向 WS は LangBot のメインポートを使用。順方向 WS は LangBot から到達可能なプロトコル側に接続します。HTTP は非対応です。 + - name: __system.onebot_ws_url + label: {en_US: Protocol endpoint connection URL, zh_Hans: 协议端连接地址, zh_Hant: 協定端連線位址, ja_JP: プロトコル側の接続先 URL} description: - en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured - zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填 - zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填 + en_US: Save the bot, then copy this URL into the protocol endpoint's WebSocket client (Universal). Old host/port settings are ignored; update the protocol endpoint URL. + zh_Hans: 保存机器人后,将此地址填入协议端的 WebSocket 客户端(Universal)配置。旧 host/port 配置不再生效,请同步修改协议端地址。 + zh_Hant: 儲存後將此位址填入協定端 WebSocket 用戶端(Universal)。舊 host/port 不再生效,請同步更新協定端位址。 + ja_JP: 保存後、この URL をプロトコル側の WebSocket クライアント(Universal)に設定。旧 host/port は無効です。接続先を更新してください。 type: string required: false - default: "" + default: '' + show_if: {field: connection_mode, operator: eq, value: reverse_ws} + - name: ws_url + label: {en_US: Protocol endpoint WebSocket URL, zh_Hans: 协议端 WebSocket 地址, zh_Hant: 協定端 WebSocket 位址, ja_JP: プロトコル側 WebSocket URL} + description: + en_US: LangBot connects to this ws:// or wss:// Universal endpoint. It must be reachable from LangBot. + zh_Hans: LangBot 将主动连接此 ws:// 或 wss:// 地址,使用协议端同时提供事件和 API 的 Universal 端点。请确保 LangBot 能访问它。 + zh_Hant: LangBot 主動連接此 ws:// 或 wss:// Universal 位址,請確保可存取。 + ja_JP: LangBot がこの ws:// または wss:// Universal URL に接続します。到達可能な URL を指定してください。 + type: string + required: true + default: '' + show_if: {field: connection_mode, operator: eq, value: forward_ws} + - name: access-token + label: {en_US: Access token, zh_Hans: 访问令牌, zh_Hant: 存取權杖, ja_JP: アクセストークン} + description: + en_US: Required in both modes. Configure the same secret token on LangBot and the protocol endpoint. Sent using Authorization Bearer. + zh_Hans: 两种模式均必填。在 LangBot 与协议端设置相同的保密令牌,连接时使用 Authorization Bearer 验证。 + zh_Hant: 兩種模式皆必填。在 LangBot 與協定端設定相同的保密權杖。 + ja_JP: 両方式で必須。LangBot とプロトコル側に同じ秘密トークンを設定してください。 + type: string + required: true + default: '' + - name: self_id + label: {en_US: Expected bot account (optional), zh_Hans: 预期机器人账号(可选), zh_Hant: 預期機器人帳號(選填), ja_JP: ボットアカウント(任意)} + description: {en_US: Reject connections or events from a different account., zh_Hans: 拒绝其他账号的连接或事件。, zh_Hant: 拒絕其他帳號的連線或事件。, ja_JP: 異なるアカウントの接続やイベントを拒否します。} + type: string + required: false + default: '' supported_events: - message.received diff --git a/src/langbot/pkg/platform/adapters/aiocqhttp/transport.py b/src/langbot/pkg/platform/adapters/aiocqhttp/transport.py new file mode 100644 index 000000000..36e09c3d7 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/aiocqhttp/transport.py @@ -0,0 +1,125 @@ +"""OneBot v11 duplex transport, independent of an HTTP server.""" + +from __future__ import annotations + +import asyncio +import functools +import json +import uuid + +from aiocqhttp.exceptions import ActionFailed, ApiNotAvailable + + +class OneBotConnection: + def __init__(self, send, receive, close, on_event, validate, *, self_id='', timeout=30): + self.send = send + self.receive = receive + self.close_socket = close + self.on_event = on_event + self.validate = validate + self.self_id = self_id + self.timeout = timeout + self.pending = {} + self.events = asyncio.Queue(maxsize=256) + self.closed = False + self.send_lock = asyncio.Lock() + + async def call_action(self, action, **params): + await self.validate() + if self.closed: + raise ApiNotAvailable('OneBot connection closed') + if len(self.pending) >= 256: + raise ApiNotAvailable('Too many pending OneBot actions') + echo = uuid.uuid4().hex + future = asyncio.get_running_loop().create_future() + self.pending[echo] = future + try: + async with asyncio.timeout(self.timeout): + async with self.send_lock: + await self.validate() + await self.send(json.dumps({'action': action, 'params': params, 'echo': echo})) + result = await future + if (result.get('status'), result.get('retcode')) not in (('ok', 0), ('async', 1)): + raise ActionFailed(result) + return result.get('data') + finally: + self.pending.pop(echo, None) + if not future.done(): + future.cancel() + + async def _read(self): + while True: + raw = await self.receive() + if len(raw) > 8 * 1024 * 1024: + raise ValueError('OneBot frame too large') + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError('OneBot frame must be an object') + await self.validate() + if 'post_type' in data: + account = str(data.get('self_id', '')) + if not account or (self.self_id and account != self.self_id): + raise ValueError('OneBot account mismatch') + self.self_id = account + # Never block responses behind an event that itself calls an API. + self.events.put_nowait(data) + else: + echo = data.get('echo') + future = self.pending.get(echo) if isinstance(echo, str) else None + if future is not None and not future.done(): + future.set_result(data) + + async def _consume(self): + while True: + data = await self.events.get() + await self.validate() + await self.on_event(data) + + async def _watch_placement(self): + while True: + await asyncio.sleep(5) + await self.validate() + + async def run(self): + tasks = [asyncio.create_task(coro()) for coro in (self._read, self._consume, self._watch_placement)] + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in done: + task.result() + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await self.close() + + async def close(self): + if self.closed: + return + self.closed = True + for future in self.pending.values(): + if not future.done(): + future.set_exception(ApiNotAvailable('OneBot connection closed')) + self.pending.clear() + await self.close_socket() + + +class OneBotClient: + """Keep the existing converter/API call surface without CQHttp's server.""" + + def __init__(self): + self.connection = None + + async def call_action(self, action, **params): + connection = self.connection + if connection is None: + raise ApiNotAvailable('OneBot protocol endpoint is not connected') + return await connection.call_action(action, **params) + + def __getattr__(self, action): + if action.startswith('_'): + raise AttributeError(action) + return functools.partial(self.call_action, action) + + async def send(self, event, message, **kwargs): + params = {key: event[key] for key in ('message_type', 'user_id', 'group_id', 'discuss_id') if key in event} + return await self.call_action('send_msg', **(params | {'message': message} | kwargs)) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 230959719..60a14087d 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -1,12 +1,15 @@ from __future__ import annotations +import time import traceback import typing import dingtalk_stream import pydantic -from langbot.libs.dingtalk_api.api import DingTalkClient +from .subscriptions import DingTalkSubscriptionHandler + +from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger @@ -39,6 +42,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler): { 'conversation_type': 'CardCallback', 'Type': 'card_callback', + 'timestamp': float(message.headers.time or 0) / 1000, 'CardCallback': { 'extension': callback.extension, 'corp_id': callback.corp_id, @@ -75,7 +79,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - required_keys = ['client_id', 'client_secret', 'robot_name', 'robot_code'] + required_keys = ['client_id', 'client_secret'] missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员') @@ -83,8 +87,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo bot = DingTalkClient( client_id=config['client_id'], client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], + robot_name=config.get('robot_name', ''), + robot_code=config.get('robot_code', ''), markdown_card=config.get('markdown_card', True), logger=logger, ) @@ -92,7 +96,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo config=config, logger=logger, card_instance_id_dict={}, - bot_account_id=config['robot_name'], + bot_account_id=config['client_id'], bot=bot, listeners={}, _message_cache={}, @@ -106,6 +110,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo return [ 'message.received', 'feedback.received', + 'group.member_joined', + 'group.member_left', + 'group.info_updated', + 'dingtalk.card_action', 'platform.specific', ] @@ -158,13 +166,28 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo assert isinstance(message_source.source_platform_object, DingTalkEvent) incoming_message = message_source.source_platform_object.incoming_message markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain( + [part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))] + ) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) raw = await self.bot.send_message(content, incoming_message, at) + await self._send_attachments(message, incoming_message) return platform_events.MessageResult( message_id=getattr(incoming_message, 'message_id', None), raw=raw if isinstance(raw, dict) else {'result': raw}, ) + async def _send_attachments(self, message, incoming_message): + failures = [] + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + try: + await self.bot.send_attachment(part, incoming_message) + except Exception as exc: + failures.append(str(exc)) + if failures: + raise RuntimeError('; '.join(failures)) + async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -174,12 +197,19 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo is_final: bool = False, ): message_id = bot_message.resp_message_id - msg_seq = bot_message.msg_sequence - if (msg_seq - 1) % 8 != 0 and not is_final: + now = time.monotonic() + last_updates = getattr(self, '_card_last_updates', None) + if last_updates is None: + self._card_last_updates = last_updates = {} + if not is_final and now - last_updates.get(message_id, 0) < 0.35: return + last_updates[message_id] = now markdown_enabled = self.config.get('markdown_card', False) - content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain( + [part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))] + ) + content, _ = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) card_instance, card_instance_id = self.card_instance_id_dict[message_id] if not content and bot_message.content: content = bot_message.content @@ -187,13 +217,17 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) if is_final and bot_message.tool_calls is None: self.card_instance_id_dict.pop(message_id) + last_updates.pop(message_id, None) + await self._send_attachments(message, message_source.source_platform_object.incoming_message) async def create_message_card(self, message_id, event): while len(self.card_instance_id_dict) >= 1000: - self.card_instance_id_dict.pop(next(iter(self.card_instance_id_dict)), None) - card_template_id = self.config['card_template_id'] + oldest_id = next(iter(self.card_instance_id_dict)) + self.card_instance_id_dict.pop(oldest_id, None) + getattr(self, '_card_last_updates', {}).pop(oldest_id, None) + card_template_id = self.config['card_template_id'].strip() incoming_message = event.source_platform_object.incoming_message - card_auto_layout = self.config.get('card_auto_layout', False) + card_auto_layout = self.config.get('card_auto_layout', True) card_instance, card_instance_id = await self.bot.create_and_card( card_template_id, incoming_message, @@ -203,7 +237,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo return True async def is_stream_output_supported(self) -> bool: - return bool(self.config.get('enable-stream-reply', False)) + return is_stream_card_configured(self.config) async def call_platform_api(self, action: str, params: dict = {}) -> dict: if action == 'interaction.request' and action in self.get_supported_apis(): @@ -253,6 +287,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo async def on_message(event: DingTalkEvent): await self._handle_native_event(event) + self.bot.client.register_all_event_handler(DingTalkSubscriptionHandler(self)) self.bot.on_message('FriendMessage')(on_message) self.bot.on_message('GroupMessage')(on_message) self.bot.client.register_callback_handler( @@ -262,6 +297,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo async def _handle_native_event(self, event: DingTalkEvent): try: + self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts) if interaction_event is not None: await self._dispatch_eba_event(interaction_event) @@ -270,14 +306,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo 'DingTalk event received: ' f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}' ) - if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: - legacy_event = await self.event_converter.target2legacy(event, self.config['robot_name']) + if event.conversation in {'FriendMessage', 'GroupMessage'} and ( + platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners + ): + legacy_event = await self.event_converter.target2legacy(event, self.bot_account_id) if legacy_event: callback = self.listeners.get(type(legacy_event)) if callback: await callback(legacy_event, self) - eba_event = await self.event_converter.target2yiri(event, self.config['robot_name']) + eba_event = await self.event_converter.target2yiri(event, self.bot_account_id) if eba_event: self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py index 51e7db193..dd7798b2d 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py @@ -16,30 +16,41 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): return getattr(event, 'source_platform_object', None) @staticmethod - async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None: + async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_events.Event | None: if event.conversation in {'FriendMessage', 'GroupMessage'}: - return await DingTalkEventConverter.message_to_eba(event, bot_name) + return await DingTalkEventConverter.message_to_eba(event, bot_id) if event.conversation == 'CardCallback': feedback = DingTalkEventConverter.card_callback_to_feedback(event) if feedback is not None: return feedback - return DingTalkEventConverter.platform_specific(event, 'card.callback') + callback = event.get('CardCallback') or {} + content = callback.get('content') or {} + return platform_events.DingTalkCardActionEvent( + adapter_name=ADAPTER_NAME, + user=platform_entities.User(id=str(callback.get('user_id') or '')), + space_id=str(callback.get('space_id') or ''), + card_instance_id=str(callback.get('card_instance_id') or ''), + action=str(content.get('action') or content.get('actionKey') or ''), + data=callback, + timestamp=float(event.get('timestamp') or 0), + source_platform_object=event, + ) return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}') @staticmethod async def target2legacy( event: DingTalkEvent, - bot_name: str, + bot_id: str, ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: - eba_event = await DingTalkEventConverter.message_to_eba(event, bot_name) + eba_event = await DingTalkEventConverter.message_to_eba(event, bot_id) if eba_event: return eba_event.to_legacy_event() return None @staticmethod - async def message_to_eba(event: DingTalkEvent, bot_name: str) -> platform_events.MessageReceivedEvent: + async def message_to_eba(event: DingTalkEvent, bot_id: str) -> platform_events.MessageReceivedEvent: incoming_message = event.incoming_message - message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name) + message_chain = await DingTalkMessageConverter.target2yiri(event, bot_id) sender = DingTalkEventConverter.user_from_event(event) chat_type = platform_entities.ChatType.PRIVATE chat_id = getattr(incoming_message, 'sender_staff_id', '') diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index 8e74f21d1..11a0b0e1f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -15,6 +15,7 @@ metadata: spec: categories: + - popular - china help_links: zh: https://langbot.app/docs/zh/usage/platforms/dingtalk @@ -27,36 +28,12 @@ spec: zh_Hans: 一键创建应用 zh_Hant: 一鍵建立應用 description: - en_US: 'Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be - obtained automatically, you need to copy it from the DingTalk Developer Backend manually.' - zh_Hans: 使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。 - zh_Hant: 使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。 + en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically. + zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。 + zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。 type: qr-code-login login_platform: dingtalk required: false - - name: human_input_card_template_download - label: - en_US: Download Human Input Card Template - zh_Hans: 下载人工输入卡片模板 - zh_Hant: 下載人工輸入卡片範本 - description: - en_US: Download the template, import it in DingTalk Card Platform, then enter its template ID below to enable native interactions. - zh_Hans: 下载模板并导入钉钉卡片平台,再将模板 ID 填入下方以启用原生交互。 - zh_Hant: 下載範本並匯入釘釘卡片平台,再將範本 ID 填入下方以啟用原生互動。 - type: download-link - required: false - default: '' - url: /api/v1/platform/adapters/dingtalk/human-input-card-template - download_filename: dingtalk_human_input_card.json - help_links: - zh: https://open-dev.dingtalk.com/fe/card - en: https://open-dev.dingtalk.com/fe/card - ja: https://open-dev.dingtalk.com/fe/card - help_label: - en_US: Import Guide - zh_Hans: 导入指引 - zh_Hant: 匯入指引 - ja_JP: インポート手順 - name: client_id label: en_US: Client ID @@ -73,75 +50,111 @@ spec: type: string required: true default: "" - - name: robot_code - label: - en_US: Robot Code - zh_Hans: 机器人代码 - zh_Hant: 機器人代碼 - type: string - required: true - default: "" - - name: robot_name - label: - en_US: Robot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - type: string - required: true - default: "" - name: markdown_card label: - en_US: Markdown Card - zh_Hans: 是否使用 Markdown 卡片 - zh_Hant: 是否使用 Markdown 卡片 + en_US: Use Card Replies + zh_Hans: 使用卡片回复 + zh_Hant: 使用卡片回覆 + description: + en_US: Controls Markdown and streaming card replies. Disable to use plain text while keeping your template settings. When enabled, a configured template automatically enables streaming. + zh_Hans: 控制 Markdown 和流式卡片回复。关闭后使用纯文本,保留模板配置;开启且已配置模板时自动使用流式回复。 + zh_Hant: 控制 Markdown 和串流卡片回覆。關閉後使用純文字,保留範本設定;開啟且已設定範本時自動使用串流回覆。 type: boolean required: false default: true - - name: enable-stream-reply - label: - en_US: Enable Stream Reply Mode - zh_Hans: 启用钉钉卡片流式回复模式 - zh_Hant: 啟用釘釘卡片串流回覆模式 - description: - en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use DingTalk card streaming replies. - zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容 - zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容 - type: boolean - required: true - default: false - - name: card_auto_layout - label: - en_US: Card Auto Layout - zh_Hans: 卡片宽屏自动布局 - zh_Hant: 卡片寬螢幕自動佈局 - type: boolean - required: false - default: false - name: card_template_id + show_if: + field: markdown_card + operator: eq + value: true label: en_US: Card Template ID zh_Hans: 卡片模板ID zh_Hant: 卡片範本ID + description: + en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming while card replies are enabled. Leave empty for ordinary replies. Separate from the human input template. + zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,开启卡片回复后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。 + zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,開啟卡片回覆後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。 type: string - required: true - default: "填写你的卡片template_id" - + required: false + default: "" + - name: stream_card_template_download + show_if: + field: markdown_card + operator: eq + value: true + label: + en_US: Download Streaming Reply Card Template + zh_Hans: 下载流式回复卡片模板 + zh_Hant: 下載串流回覆卡片範本 + description: + en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting. + zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。 + zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。 + type: download-link + required: false + default: '' + url: /api/v1/platform/adapters/dingtalk/stream-card-template + download_filename: dingtalk_stream_card.json + - name: card_auto_layout + show_if: + field: markdown_card + operator: eq + value: true + label: + en_US: Card Auto Layout + zh_Hans: 卡片宽屏自动布局 + zh_Hant: 卡片寬螢幕自動佈局 + description: + en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout. + zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。 + zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。 + type: boolean + required: false + default: true - name: human_input_card_template_id label: en_US: Human Input Card Template ID zh_Hans: 人工输入卡片模板 ID zh_Hant: 人工輸入卡片範本 ID description: - en_US: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions. - zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。 - zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。 + en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID. + zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。 + zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。 type: string required: false default: "" + - name: human_input_card_template_download + label: + en_US: Download Human Input Card Template + zh_Hans: 下载人工输入卡片模板 + zh_Hant: 下載人工輸入卡片範本 + description: + en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above. + zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。 + zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。 + type: download-link + required: false + default: '' + url: /api/v1/platform/adapters/dingtalk/human-input-card-template + download_filename: dingtalk_human_input_card.json + help_links: + zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板 + en: https://open-dev.dingtalk.com/fe/card + ja: https://open-dev.dingtalk.com/fe/card + help_label: + en_US: Import Guide + zh_Hans: 导入指引 + zh_Hant: 匯入指引 + ja_JP: インポート手順 supported_events: - message.received - feedback.received + - group.member_joined + - group.member_left + - group.info_updated + - dingtalk.card_action - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py index 92bd44d6b..253eed8d1 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/message_converter.py @@ -86,7 +86,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte return content, at @staticmethod - async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_message.MessageChain: + async def target2yiri(event: DingTalkEvent, bot_id: str) -> platform_message.MessageChain: incoming_message = event.incoming_message components: list[platform_message.MessageComponent] = [ platform_message.Source( @@ -95,15 +95,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte ) ] - for at_user in getattr(incoming_message, 'at_users', []) or []: - if getattr(at_user, 'dingtalk_id', None) == getattr(incoming_message, 'chatbot_user_id', None): - components.append(platform_message.At(target=bot_name, display=bot_name)) + chatbot_id = getattr(incoming_message, 'chatbot_user_id', None) + mentioned = getattr(incoming_message, 'is_in_at_list', False) or any( + chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id + for at_user in getattr(incoming_message, 'at_users', []) or [] + ) + if mentioned: + components.append(platform_message.At(target=bot_id)) rich_content = event.rich_content if rich_content: for element in rich_content.get('Elements') or []: if element.get('Type') == 'text': - text = DingTalkMessageConverter._strip_bot_mention(element.get('Content', ''), bot_name) + text = element.get('Content', '') if text.strip(): components.append(platform_message.Plain(text=text)) elif element.get('Type') == 'image' and element.get('Picture'): @@ -112,7 +116,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte if event.content and event.type != 'audio': components.append( platform_message.Plain( - text=DingTalkMessageConverter._strip_bot_mention(event.content, bot_name), + text=event.content, ) ) if event.picture: @@ -160,10 +164,6 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte origin=platform_message.MessageChain(origin_components), ) - @staticmethod - def _strip_bot_mention(text: str, bot_name: str) -> str: - return text.replace('@' + bot_name, '') - @staticmethod def _message_time(incoming_message: typing.Any) -> datetime.datetime: value = getattr(incoming_message, 'create_at', None) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/subscriptions.py b/src/langbot/pkg/platform/adapters/dingtalk/subscriptions.py new file mode 100644 index 000000000..3ebabed8d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/dingtalk/subscriptions.py @@ -0,0 +1,91 @@ +"""Translate DingTalk Stream EVENT envelopes, keeping unknown payloads intact.""" + +import asyncio +from collections import OrderedDict + +import dingtalk_stream +from langbot_plugin.api.entities.builtin.platform import entities, events + +from .types import ADAPTER_NAME + + +def subscription_events(message): + data = message.data + headers = message.headers + action = headers.event_type or data.get('EventType') or data.get('eventType') or '' + raw = {'headers': vars(headers).copy(), 'data': data} + stamp = ( + headers.event_born_time + or data.get('TimeStamp') + or data.get('EventTime') + or data.get('timestamp') + or headers.time + or 0 + ) + stamp = float(stamp) + common = dict( + adapter_name=ADAPTER_NAME, + timestamp=stamp / 1000 if stamp > 10_000_000_000 else stamp, + source_platform_object=raw, + ) + group_id = ( + data.get('OpenConversationId') or data.get('openConversationId') or data.get('chatId') or data.get('ChatId') + ) + group = entities.UserGroup(id=str(group_id or ''), name=str(data.get('Title') or data.get('title') or '')) + owner_id = data.get('Owner') or data.get('owner') + if owner_id: + group.owner_id = str(owner_id) + operator_id = data.get('Operator') or data.get('operator') + operator = entities.User(id=str(operator_id)) if operator_id else None + members = data.get('UserId') or data.get('userId') or [] + if isinstance(members, str): + members = [members] + if group_id and members and action in {'chat_add_member', 'chat_remove_member', 'chat_quit'}: + for member_id in dict.fromkeys(members): + member = entities.User(id=str(member_id)) + if action == 'chat_add_member': + yield events.MemberJoinedEvent(group=group, member=member, inviter=operator, **common) + else: + yield events.MemberLeftEvent( + group=group, + member=member, + operator=operator, + is_kicked=action == 'chat_remove_member', + **common, + ) + elif group_id and action in {'chat_update_title', 'chat_update_owner'}: + yield events.GroupInfoUpdatedEvent( + group=group, + operator=operator, + changed_fields=['name' if action == 'chat_update_title' else 'owner_id'], + **common, + ) + else: + yield events.PlatformSpecificEvent(action=action or 'unknown', data=raw, **common) + + +class DingTalkSubscriptionHandler(dingtalk_stream.EventHandler): + def __init__(self, adapter): + super().__init__() + self.adapter = adapter + self.completed = OrderedDict() + self.lock = asyncio.Lock() + + async def process(self, message): + # Serialize retries; mark each fan-out event only after successful dispatch. + async with self.lock: + try: + identity = message.headers.event_id or message.headers.message_id + for index, event in enumerate(subscription_events(message)): + key = (message.headers.event_corp_id, identity, index) + if identity and key in self.completed: + continue + await self.adapter._dispatch_eba_event(event) + if identity: + self.completed[key] = None + while len(self.completed) > 4096: + self.completed.popitem(last=False) + return dingtalk_stream.AckMessage.STATUS_OK, 'OK' + except Exception: + self.logger.exception('DingTalk subscription dispatch failed') + return 500, 'Event dispatch failed' diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py index 8ace25ba7..374c008cf 100644 --- a/src/langbot/pkg/platform/adapters/discord/adapter.py +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -92,40 +92,40 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}') async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message): + if after.author.bot: + return await adapter_self._dispatch_gateway_tuple( 'message_edit', (before, after), self.user.id if self.user else None ) - async def on_message_delete(self: discord.Client, message: discord.Message): - await adapter_self._dispatch_gateway_tuple( - 'message_delete', message, self.user.id if self.user else None - ) + async def on_raw_message_edit(self: discord.Client, payload: discord.RawMessageUpdateEvent): + # Cached edits are handled above. Ignore embed-only updates, which + # Discord also emits after resolving previews. + if payload.cached_message is not None or not {'content', 'attachments'} & payload.data.keys(): + return + if payload.data.get('author', {}).get('bot'): + return + try: + channel = await adapter_self._get_channel(payload.channel_id) + after = await channel.fetch_message(payload.message_id) + except discord.HTTPException: + await adapter_self.logger.warning('Unable to fetch edited Discord message') + return + if not after.author.bot: + await adapter_self._dispatch_gateway_tuple( + 'message_edit', (None, after), self.user.id if self.user else None + ) async def on_raw_message_delete(self: discord.Client, payload: discord.RawMessageDeleteEvent): + # Raw callbacks also fire for cached messages. Dispatch only once, + # retaining the cached payload when Discord supplies one. + cached = getattr(payload, 'cached_message', None) await adapter_self._dispatch_gateway_tuple( - 'raw_message_delete', - payload, + 'message_delete' if cached is not None else 'raw_message_delete', + cached if cached is not None else payload, self.user.id if self.user else None, ) - async def on_reaction_add( - self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member - ): - if self.user and user.id == self.user.id: - return - await adapter_self._dispatch_gateway_tuple( - 'reaction_add', (reaction, user), self.user.id if self.user else None - ) - - async def on_reaction_remove( - self: discord.Client, reaction: discord.Reaction, user: discord.User | discord.Member - ): - if self.user and user.id == self.user.id: - return - await adapter_self._dispatch_gateway_tuple( - 'reaction_remove', (reaction, user), self.user.id if self.user else None - ) - async def on_raw_reaction_add(self: discord.Client, payload: discord.RawReactionActionEvent): if self.user and payload.user_id == self.user.id: return @@ -212,7 +212,15 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): content, files = await self.message_converter.yiri2target(message) - channel = await self._get_channel(target_id) + guild = self.bot.get_guild(int(target_id)) if target_type == 'group' else None + if guild is not None: + # Guild-level events have no originating channel. Use only the + # server's explicitly configured system channel for their replies. + channel = guild.system_channel + if channel is None: + raise ValueError('Discord guild has no system channel configured for event replies') + else: + channel = await self._get_channel(target_id) kwargs = {'content': content} if files: kwargs['files'] = files @@ -323,58 +331,55 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform else getattr(bot_message, 'resp_message_id', None) ) - text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)] - chunk_text = '\n\n'.join(t for t in text_parts if t) - + tool_calls = ( + bot_message.get('tool_calls') if isinstance(bot_message, dict) else getattr(bot_message, 'tool_calls', None) + ) + terminal = is_final and not tool_calls + chunk_text = '\n\n'.join(m.text for m in message if isinstance(m, platform_message.Plain) and m.text) ctx = self._stream_buffer.get(msg_id) if msg_id else None - if ctx is not None: - ctx['updated_at'] = time.time() - if ctx is None: - try: - if is_final and chunk_text: - await self.reply_message(message_source, message, quote_origin) - finally: - self._stream_buffer.pop(msg_id, None) + if terminal and message: + await self.reply_message(message_source, message, quote_origin) return - # Progressive streaming path: send first chunk, edit subsequent. + ctx['updated_at'] = time.time() ctx['chunk_count'] += 1 - - # Runner yields the full accumulated text on each chunk, so we - # always replace (not append). if chunk_text: - ctx['last_content'] = chunk_text[:2000] + ctx['last_content'] = chunk_text - sent = ctx['sent_message'] - - if sent is None: - # First non-empty chunk — send the initial message. - if not ctx['last_content']: - return # No content yet, wait for next chunk. - try: - sent = await ctx['channel'].send(ctx['last_content']) - ctx['sent_message'] = sent - except Exception: - await self.logger.error(f'Discord stream send failed: {traceback.format_exc()}') + try: + content = ctx['last_content'] + sent = ctx['sent_message'] + if content: + # Discord limits each message to 2000 characters. Keep the first + # message as the live preview and deliver the remainder at completion. + preview = content[:2000] + if sent is None: + sent = await ctx['channel'].send(preview) + ctx['sent_message'] = sent + elif is_final or ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL == 0: + if preview != sent.content: + try: + ctx['sent_message'] = await sent.edit(content=preview) + except discord.NotFound: + ctx['sent_message'] = await ctx['channel'].send(preview) + if terminal: + for offset in range(2000, len(content), 2000): + await ctx['channel'].send(content[offset : offset + 2000]) + if terminal: + attachments = platform_message.MessageChain( + [ + part + for part in message + if isinstance(part, (platform_message.Image, platform_message.File, platform_message.Voice)) + ] + ) + if attachments: + await self.reply_message(message_source, attachments, quote_origin) + finally: + # Tool-call completions are intermediate rounds, not the end of a reply. + if terminal: self._stream_buffer.pop(msg_id, None) - return - - if is_final: - # Final chunk — edit to the full content, then clean up. - if ctx['last_content'] and ctx['last_content'] != sent.content: - try: - await sent.edit(content=ctx['last_content'][:2000]) - except Exception: - pass # Best-effort - self._stream_buffer.pop(msg_id, None) - elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0: - # Intermediate edit — throttle to avoid rate limits. - if ctx['last_content'] and ctx['last_content'] != sent.content: - try: - await sent.edit(content=ctx['last_content'][:2000]) - except Exception: - pass # Rate-limited or deleted — ignore. async def run_async(self): await self.bot.start(self.config['token'], reconnect=True) diff --git a/src/langbot/pkg/platform/adapters/discord/event_converter.py b/src/langbot/pkg/platform/adapters/discord/event_converter.py index d34839725..e8bb49d4d 100644 --- a/src/langbot/pkg/platform/adapters/discord/event_converter.py +++ b/src/langbot/pkg/platform/adapters/discord/event_converter.py @@ -69,7 +69,7 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def message_edit_to_eba( - before: discord.Message, after: discord.Message + before: discord.Message | None, after: discord.Message ) -> platform_events.MessageEditedEvent: return platform_events.MessageEditedEvent( type='message.edited', diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 6c8b9503f..ec0635968 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -1,5 +1,7 @@ from __future__ import annotations +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + from langbot.pkg.platform.sources.lark import ( LarkAdapter as LegacyLarkAdapter, NonBlockingLarkWSClient, @@ -47,6 +49,8 @@ from lark_oapi.api.im.v1 import ( CreateMessageResponse, EventMessage, EventSender, + GetChatRequest, + GetMessageRequest, P2ImMessageReceiveV1, P2ImMessageReceiveV1Data, ReplyMessageRequest, @@ -57,6 +61,8 @@ import lark_oapi.ws.exception import pydantic import quart +from .stream_card import build_card, streaming_text + import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin @@ -141,13 +147,15 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte _message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict) _user_cache: dict[str, platform_entities.User] = pydantic.PrivateAttr(default_factory=dict) _group_cache: dict[str, platform_entities.UserGroup] = pydantic.PrivateAttr(default_factory=dict) + _bot_open_ids: dict[str, str] = pydantic.PrivateAttr(default_factory=dict) + _bot_identity_lock: asyncio.Lock = pydantic.PrivateAttr(default_factory=asyncio.Lock) _monitoring_mapping_ttl: int = 600 class Config: arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): - required_keys = ['app_id', 'app_secret', 'bot_name'] + required_keys = ['app_id', 'app_secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise ValueError(f'Lark missing required config: {", ".join(missing_keys)}') @@ -166,7 +174,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte config=config, logger=logger, lark_tenant_key=config.get('lark_tenant_key', ''), - bot_account_id=config['bot_name'], + bot_account_id=config['app_id'], bot=bot, api_client=api_client, quart_app=quart.Quart(__name__), @@ -196,17 +204,32 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte def sync_on_card_action(event): return self._handle_card_action_sync(event) - return ( + def sync_on_bot_added(event): + self._submit_coro(self._handle_bot_added_event(event)) + + def sync_on_standard_event(event): + self._submit_coro(self._handle_standard_event(json.loads(lark_oapi.JSON.marshal(event)))) + + builder = ( lark_oapi.EventDispatcherHandler.builder('', '') .register_p2_im_message_receive_v1(sync_on_message) + .register_p2_im_chat_member_bot_added_v1(sync_on_bot_added) .register_p2_card_action_trigger(sync_on_card_action) - .build() ) + for registration in LarkEventConverter.STANDARD_EVENT_CALLBACKS.values(): + getattr(builder, registration)(sync_on_standard_event) + return builder.build() def get_supported_events(self) -> list[str]: return [ 'message.received', 'bot.invited_to_group', + 'bot.removed_from_group', + 'group.member_joined', + 'group.member_left', + 'group.info_updated', + 'message.deleted', + 'message.reaction', 'platform.specific', ] @@ -327,6 +350,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self.request_tenant_access_token(tenant_key) return self.tenant_access_tokens.get(tenant_key, {}).get('token') + @localized_adapter async def send_message( self, target_type: str, @@ -360,6 +384,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte message_id=message_ids[-1] if message_ids else '', raw={'message_ids': message_ids} ) + @localized_adapter async def reply_message( self, message_source: platform_events.MessageEvent, @@ -472,28 +497,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self.card_sequence_dict.pop(old_card, None) self.card_last_update_dict.pop(old_card, None) self.closed_streaming_cards.discard(old_card) - card_data = { - 'schema': '2.0', - 'config': { - 'update_multi': True, - 'streaming_mode': True, - 'streaming_config': { - 'print_step': {'default': 1}, - 'print_frequency_ms': {'default': 70}, - 'print_strategy': 'fast', - }, - }, - 'body': { - 'direction': 'vertical', - 'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}], - }, - } + card_data = build_card(streaming=True) request = ( CreateCardRequest.builder() .request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build()) .build() ) - response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request) + response: CreateCardResponse = await self.api_client.cardkit.v1.card.acreate(request) if not response.success(): raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}') card_id = str(response.data.card_id) @@ -513,16 +523,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte def _streaming_mode_closed(response: ContentCardElementResponse) -> bool: return response.code == 300309 or 'streaming mode is closed' in str(response.msg).lower() - async def _replace_streaming_card(self, card_id: str, content: str) -> None: + async def _replace_streaming_card(self, card_id: str, content: str, *, finished: bool = True) -> None: sequence = self._next_card_sequence(card_id) - card_data = { - 'schema': '2.0', - 'config': {'update_multi': True}, - 'body': { - 'direction': 'vertical', - 'elements': [{'tag': 'markdown', 'content': content}], - }, - } + card_data = build_card(content, finished=finished) request = ( UpdateCardRequest.builder() .card_id(card_id) @@ -540,6 +543,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}') self.closed_streaming_cards.add(card_id) + @localized_adapter async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -565,14 +569,16 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte for paragraph in text_elements ) if (is_final and not bot_message.tool_calls) or card_id in self.closed_streaming_cards: - await self._replace_streaming_card(card_id, content) + await self._replace_streaming_card(card_id, content, finished=is_final and not bot_message.tool_calls) else: sequence = self._next_card_sequence(card_id) request = ( ContentCardElementRequest.builder() .card_id(card_id) .element_id('streaming_txt') - .request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build()) + .request_body( + ContentCardElementRequestBody.builder().content(streaming_text(content)).sequence(sequence).build() + ) .build() ) response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent( @@ -580,7 +586,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte ) if not response.success(): if self._streaming_mode_closed(response): - await self._replace_streaming_card(card_id, content) + await self._replace_streaming_card( + card_id, content, finished=is_final and not bot_message.tool_calls + ) else: raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}') self.card_last_update_dict[card_id] = now @@ -590,6 +598,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self.card_last_update_dict.pop(card_id, None) self.closed_streaming_cards.discard(card_id) + @localized_adapter async def call_platform_api(self, action: str, params: dict = {}) -> dict: if action == 'interaction.request': return await send_interaction(self, params) @@ -663,6 +672,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte token = data.get('token') return isinstance(token, str) and hmac.compare_digest(expected.encode('utf-8'), token.encode('utf-8')) + @localized_adapter async def handle_unified_webhook(self, bot_uuid: str, path: str, request): try: raw_body = await request.get_data() @@ -687,6 +697,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte if event_type == 'app_ticket': self.app_ticket = self._webhook_event(data).get('app_ticket') return {'code': 200, 'message': 'ok'} + if event_type in LarkEventConverter.STANDARD_EVENT_CALLBACKS: + await self._handle_standard_event(data) + return {'code': 200, 'message': 'ok'} if event_type == 'im.message.receive_v1': p2v1 = P2ImMessageReceiveV1() p2v1.header = self._webhook_header(data) @@ -699,11 +712,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte await self._handle_message_event(p2v1) return {'code': 200, 'message': 'ok'} if event_type == 'im.chat.member.bot.added_v1': - raw_event = self._webhook_event(data) - header = self._webhook_header(data) - chat_id = raw_event.get('chat_id', '') - await self._send_bot_added_welcome(chat_id, getattr(header, 'tenant_key', None)) - await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id)) + await self._handle_bot_added_event(lark_oapi.im.v1.P2ImChatMemberBotAddedV1(data)) return {'code': 200, 'message': 'ok'} if event_type == 'card.action.trigger': interaction_event = interaction_event_from_webhook(data) @@ -713,7 +722,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte feedback_event = self._feedback_event_from_webhook(data) if feedback_event and platform_events.FeedbackEvent in self.listeners: await self.listeners[platform_events.FeedbackEvent](feedback_event, self) - return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} await self._dispatch_eba_event(LarkEventConverter.platform_specific(data, event_type, data)) return {'code': 200, 'message': 'ok'} except Exception: @@ -734,8 +743,14 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte def _webhook_header(self, data: dict): return type('LarkWebhookHeader', (), data.get('header', {}))() + @localized_adapter async def run_async(self): self.event_loop = asyncio.get_running_loop() + if self.config.get('app_type', 'self') != 'isv': + try: + await self._get_bot_open_id(None) + except Exception as exc: + await self.logger.warning(f'Lark bot identity lookup failed; will retry on incoming message: {exc}') if not self.config.get('enable-webhook', False): try: await self.bot._connect() @@ -780,13 +795,118 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte async def is_muted(self, group_id: int | None = None) -> bool: return False + async def _standard_message_context(self, raw: dict) -> dict: + data = raw['event'] + cached = self._message_cache.get(data['message_id']) + if cached: + return {'chat_type': cached.chat_type, 'chat_id': cached.chat_id, 'group': cached.group} + tenant_key = (raw.get('header') or {}).get('tenant_key') + option = await asyncio.to_thread(self.request_option, tenant_key) + chat_id = data.get('chat_id') + if not chat_id: + request = GetMessageRequest.builder().message_id(data['message_id']).build() + response = await self.api_client.im.v1.message.aget(request, option) + if not response.success() or not response.data or not response.data.items: + raise RuntimeError(f'Lark message context lookup failed: {response.code} {response.msg}') + chat_id = response.data.items[0].chat_id + request = GetChatRequest.builder().chat_id(chat_id).build() + response = await self.api_client.im.v1.chat.aget(request, option) + if not response.success() or not response.data: + raise RuntimeError(f'Lark chat context lookup failed: {response.code} {response.msg}') + mode = response.data.chat_mode + if mode not in {'group', 'p2p'}: + raise RuntimeError(f'Lark chat context has unknown chat mode: {mode}') + is_group = mode == 'group' + return { + 'chat_type': platform_entities.ChatType.GROUP if is_group else platform_entities.ChatType.PRIVATE, + 'chat_id': chat_id, + 'group': platform_entities.UserGroup(id=chat_id, name=response.data.name or '') if is_group else None, + } + + async def _handle_standard_event(self, raw: dict): + event_type = (raw.get('header') or {}).get('event_type', 'unknown') + await self.logger.info(f'Lark event received: {event_type}') + try: + await self._process_standard_event(raw) + except Exception: + await self.logger.error(f'Lark event processing failed ({event_type}): {traceback.format_exc()}') + raise + + async def _process_standard_event(self, raw: dict): + event_type = raw['header']['event_type'] + context = None + if event_type.startswith('im.message.'): + try: + context = await self._standard_message_context(raw) + except Exception as exc: + # Preserve the event without falsely classifying an unknown conversation as a private chat. + await self.logger.warning(f'Lark {event_type}: {exc}; forwarding original platform event') + await self._dispatch_eba_event(LarkEventConverter.platform_specific(raw, event_type, raw)) + return + events = LarkEventConverter.standard_events(raw, context) + if not events: + await self.logger.warning(f'Lark {event_type}: no standard events could be extracted') + for event in events: + if isinstance(event, (platform_events.GroupInfoUpdatedEvent, platform_events.BotRemovedFromGroupEvent)): + self._group_cache.pop(str(event.group.id), None) + await self._dispatch_eba_event(event) + await self.logger.info(f'Lark event dispatched: {event_type}, count={len(events)}') + if event_type == 'im.message.recalled_v1': + self._message_cache.pop(raw['event']['message_id'], None) + + async def _handle_bot_added_event(self, event: lark_oapi.im.v1.P2ImChatMemberBotAddedV1): + chat_id = event.event.chat_id + operator = event.event.operator_id + operator_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) + await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(event, chat_id, operator_id)) + try: + await self._send_bot_added_welcome(chat_id, getattr(event.header, 'tenant_key', None)) + except Exception: + await self.logger.warning(f'Lark bot_added_welcome failed: {traceback.format_exc()}') + + async def _get_bot_open_id(self, tenant_key: str | None) -> str: + key = tenant_key or '' + async with self._bot_identity_lock: + if key in self._bot_open_ids: + return self._bot_open_ids[key] + request = ( + lark_oapi.BaseRequest.builder() + .http_method(lark_oapi.HttpMethod.GET) + .uri('/open-apis/bot/v3/info') + .token_types({lark_oapi.AccessTokenType.TENANT}) + .build() + ) + option = await asyncio.to_thread(self.request_option, tenant_key) + response = await self.api_client.arequest(request, option) + if not response.success(): + raise RuntimeError(f'Lark bot identity lookup failed: {response.code} {response.msg}') + bot_info = json.loads(response.raw.content).get('bot') or {} + open_id = bot_info.get('open_id') or bot_info.get('openid') + if not isinstance(open_id, str) or not open_id: + raise RuntimeError('Lark bot identity response is missing open_id') + # Keep a stable adapter identity across tenant-specific callbacks. + if not self._bot_open_ids: + self.bot_account_id = open_id + self._bot_open_ids[key] = open_id + return open_id + + def _normalize_bot_mention(self, event, open_id: str) -> None: + if event is not None: + for component in event.message_chain: + if isinstance(component, platform_message.At) and str(component.target) == open_id: + component.target = self.bot_account_id + async def _handle_message_event(self, event: lark_oapi.im.v1.P2ImMessageReceiveV1): try: + tenant_key = getattr(getattr(event, 'header', None), 'tenant_key', None) + open_id = await self._get_bot_open_id(tenant_key if self.config.get('app_type', 'self') == 'isv' else None) if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event, self.api_client) + self._normalize_bot_mention(legacy_event, open_id) if legacy_event and type(legacy_event) in self.listeners: await self.listeners[type(legacy_event)](legacy_event, self) eba_event = await self.event_converter.target2yiri(event, self.api_client) + self._normalize_bot_mention(eba_event, open_id) if eba_event: self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) @@ -827,12 +947,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} + ) - @staticmethod - def _interaction_action_response(event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]: + def _interaction_action_response(self, event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]: response: dict[str, typing.Any] = { - 'toast': {'type': 'success', 'content': 'Submitted / 已提交'}, + 'toast': {'type': 'success', 'content': adapter_t(self, 'Submitted')}, } if not event.data.get('cardkit'): response['card'] = { @@ -842,7 +963,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte 'elements': [ { 'tag': 'div', - 'text': {'tag': 'lark_md', 'content': '**Submitted / 已提交**'}, + 'text': {'tag': 'lark_md', 'content': '**' + adapter_t(self, 'Submitted') + '**'}, } ], }, diff --git a/src/langbot/pkg/platform/adapters/lark/event_converter.py b/src/langbot/pkg/platform/adapters/lark/event_converter.py index d76a03ab8..b2736009d 100644 --- a/src/langbot/pkg/platform/adapters/lark/event_converter.py +++ b/src/langbot/pkg/platform/adapters/lark/event_converter.py @@ -15,6 +15,103 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter): + STANDARD_EVENT_CALLBACKS: typing.ClassVar[dict[str, str]] = { + 'im.chat.member.bot.deleted_v1': 'register_p2_im_chat_member_bot_deleted_v1', + 'im.chat.member.user.added_v1': 'register_p2_im_chat_member_user_added_v1', + 'im.chat.member.user.deleted_v1': 'register_p2_im_chat_member_user_deleted_v1', + 'im.chat.updated_v1': 'register_p2_im_chat_updated_v1', + 'im.message.recalled_v1': 'register_p2_im_message_recalled_v1', + 'im.message.reaction.created_v1': 'register_p2_im_message_reaction_created_v1', + 'im.message.reaction.deleted_v1': 'register_p2_im_message_reaction_deleted_v1', + } + + @staticmethod + def _event_user(identity: dict | None, name: str = '') -> platform_entities.User | None: + identity = identity or {} + user_id = identity.get('open_id') or identity.get('user_id') or identity.get('union_id') + return platform_entities.User(id=user_id, nickname=name) if user_id else None + + @classmethod + def standard_events(cls, raw: dict, message_context: dict | None = None) -> list[platform_events.EBAEvent]: + data = raw.get('event') or {} + header = raw.get('header') or {} + event_type = header.get('event_type', '') + common = { + 'adapter_name': ADAPTER_NAME, + 'timestamp': cls._timestamp(data.get('action_time') or data.get('recall_time') or header.get('create_time')) + or time.time(), + 'source_platform_object': raw, + } + group = platform_entities.UserGroup(id=data.get('chat_id') or '', name=data.get('name') or '') + operator = cls._event_user(data.get('operator_id')) + if event_type == 'im.chat.member.bot.deleted_v1': + return [platform_events.BotRemovedFromGroupEvent(group=group, operator=operator, **common)] + if event_type in {'im.chat.member.user.added_v1', 'im.chat.member.user.deleted_v1'}: + events = [] + for user in data.get('users') or []: + member = cls._event_user(user.get('user_id'), user.get('name') or '') + if member is None: + continue + if event_type == 'im.chat.member.user.added_v1': + inviter = operator if operator and operator.id != member.id else None + events.append( + platform_events.MemberJoinedEvent( + group=group, + member=member, + inviter=inviter, + join_type='invite' if inviter else None, + **common, + ) + ) + else: + events.append( + platform_events.MemberLeftEvent( + group=group, + member=member, + operator=operator, + is_kicked=operator is not None and operator.id != member.id, + **common, + ) + ) + return events + if event_type == 'im.chat.updated_v1': + after = data.get('after_change') or {} + before = data.get('before_change') or {} + changed = [key for key in before.keys() | after.keys() if before.get(key) != after.get(key)] + if data.get('moderator_list'): + changed.append('moderator_list') + group.name = after.get('name') or '' + group.description = after.get('description') + group.avatar_url = after.get('avatar') + owner = cls._event_user(after.get('owner_id')) + group.owner_id = owner.id if owner else None + return [ + platform_events.GroupInfoUpdatedEvent( + group=group, + operator=operator, + changed_fields=sorted(changed), + **common, + ) + ] + context = message_context or {} + if event_type == 'im.message.recalled_v1': + return [platform_events.MessageDeletedEvent(message_id=data['message_id'], **context, **common)] + if event_type in {'im.message.reaction.created_v1', 'im.message.reaction.deleted_v1'}: + user = cls._event_user(data.get('user_id')) + if user is None: + user = platform_entities.User(id=data.get('app_id') or '', is_bot=data.get('operator_type') == 'app') + return [ + platform_events.MessageReactionEvent( + message_id=data['message_id'], + user=user, + reaction=(data.get('reaction_type') or {}).get('emoji_type') or '', + is_add=event_type == 'im.message.reaction.created_v1', + **context, + **common, + ) + ] + return [] + _processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {} _processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096 _processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400 diff --git a/src/langbot/pkg/platform/adapters/lark/interaction.py b/src/langbot/pkg/platform/adapters/lark/interaction.py index 9c9873125..8896f6316 100644 --- a/src/langbot/pkg/platform/adapters/lark/interaction.py +++ b/src/langbot/pkg/platform/adapters/lark/interaction.py @@ -2,6 +2,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t + import json import time import typing @@ -61,8 +63,7 @@ def _field_form_elements( component_name = 'lbi_field_0' label = str(field.get('label') or field_id) placeholder = str( - field.get('placeholder') - or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入') + field.get('placeholder') or (t('Select an option') if field_type == 'select' else t('Enter a value')) ) required = bool(field.get('required')) if field_type == 'select': @@ -122,7 +123,7 @@ def _field_form_elements( submit_button = { 'tag': 'button', 'name': 'lbi_submit', - 'text': {'tag': 'plain_text', 'content': 'Submit / 提交'}, + 'text': {'tag': 'plain_text', 'content': t('Submit')}, 'type': 'primary', 'width': 'fill', 'form_action_type': 'submit', diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index b60a04904..f13f84369 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -118,20 +118,6 @@ spec: type: string required: true default: "" - - name: bot_name - label: - en_US: Bot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - ja_JP: ボット名 - description: - en_US: Must match the Lark bot name so group mentions can be recognized. - zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常识别 @。 - zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常識別 @。 - ja_JP: グループメンションを認識するには Lark のボット名と一致する必要があります。 - type: string - required: true - default: "" - name: enable-webhook label: en_US: Enable Webhook Mode @@ -259,6 +245,12 @@ spec: supported_events: - message.received - bot.invited_to_group + - bot.removed_from_group + - group.member_joined + - group.member_left + - group.info_updated + - message.deleted + - message.reaction - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/lark/message_converter.py b/src/langbot/pkg/platform/adapters/lark/message_converter.py index 117740d01..510246cf8 100644 --- a/src/langbot/pkg/platform/adapters/lark/message_converter.py +++ b/src/langbot/pkg/platform/adapters/lark/message_converter.py @@ -241,6 +241,13 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): flattened.append(ele) elif isinstance(ele, list): flattened.extend(item for item in ele if isinstance(item, dict)) + for index, ele in enumerate(flattened): + if ele.get('tag') == 'at': + mentions = LarkMessageConverter._split_text_mentions( + ele.get('user_id') or '', getattr(message, 'mentions', []) or [] + ) + if len(mentions) == 1 and mentions[0].get('tag') == 'at': + flattened[index] = {**ele, **mentions[0]} return flattened if message.message_type == 'image': return [{'tag': 'img', 'image_key': content.get('image_key', ''), 'style': []}] @@ -275,10 +282,15 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): result.append({'tag': 'text', 'text': text[pos : match.start()], 'style': []}) mention = mention_by_key.get(match.group(0)) if mention: + identity = getattr(mention, 'id', None) + if isinstance(identity, dict): + identity = identity.get('open_id') or identity.get('user_id') + elif identity is not None and not isinstance(identity, str): + identity = getattr(identity, 'open_id', None) or getattr(identity, 'user_id', None) result.append( { 'tag': 'at', - 'user_id': getattr(mention, 'id', None) + 'user_id': identity or getattr(mention, 'open_id', None) or getattr(mention, 'user_id', None) or getattr(mention, 'key', match.group(0)), diff --git a/src/langbot/pkg/platform/adapters/lark/stream_card.py b/src/langbot/pkg/platform/adapters/lark/stream_card.py new file mode 100644 index 000000000..cd5adbd56 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/stream_card.py @@ -0,0 +1,99 @@ +"""Compact streaming previews and a final card with collapsible reasoning.""" + +import re + +from langbot.pkg.i18n import t + + +def split_content(content: str) -> tuple[str, str]: + reasoning, answer = [], [] + inside, offset = False, 0 + for marker in re.finditer(r'', content, re.IGNORECASE): + (reasoning if inside else answer).append(content[offset : marker.start()]) + inside = marker.group().lower() == '' + offset = marker.end() + tail = content[offset:] + for size in range(min(7, len(tail)), 0, -1): + if any(tag.startswith(tail[-size:].lower()) for tag in ('', '')): + tail = tail[:-size] + break + (reasoning if inside else answer).append(tail) + return tuple('\n\n'.join(part.strip() for part in pieces if part.strip()) for pieces in (reasoning, answer)) + + +def streaming_text(content: str) -> str: + reasoning, answer = split_content(content) + if not reasoning: + return answer + # Keep live cards compact; the final card retains the complete reasoning. + preview = reasoning[-360:] + if len(reasoning) > 360: + preview = '…' + preview + quote = '> ' + preview.replace('\n', '\n> ') + return '**' + t('Thinking') + '**\n\n' + quote + ('\n\n---\n\n' + answer if answer else '') + + +def build_card(content: str = '', *, streaming: bool = False, finished: bool = True) -> dict: + reasoning, answer = split_content(content) + elements = [] + if streaming: + elements.append({'tag': 'markdown', 'content': streaming_text(content), 'element_id': 'streaming_txt'}) + else: + if reasoning: + elements.append( + { + 'tag': 'collapsible_panel', + 'expanded': not finished, + 'header': {'title': {'tag': 'plain_text', 'content': t('Thinking')}}, + 'elements': [{'tag': 'markdown', 'content': reasoning}], + } + ) + if answer: + elements.append({'tag': 'markdown', 'content': answer}) + elements.append( + { + 'tag': 'column_set', + 'horizontal_spacing': '8px', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'weight': 1, + 'vertical_align': 'center', + 'elements': [{'tag': 'markdown', 'content': t('AI-generated'), 'text_size': 'notation'}], + }, + { + 'tag': 'column', + 'width': 'auto', + 'vertical_align': 'center', + 'elements': [ + { + 'tag': 'markdown', + 'content': '[LangBot](https://langbot.app?utm_source=feishu&utm_medium=bot_card&utm_campaign=langbot)', + 'text_size': 'notation', + 'text_align': 'right', + 'icon': { + 'tag': 'custom_icon', + 'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg', + }, + } + ], + }, + ], + } + ) + config = {'update_multi': True} + if streaming: + config.update( + streaming_mode=True, + streaming_config={ + 'print_step': {'default': 3}, + 'print_frequency_ms': {'default': 40}, + 'print_strategy': 'fast', + }, + ) + return { + 'schema': '2.0', + 'config': config, + 'body': {'direction': 'vertical', 'padding': '12px 12px 12px 12px', 'elements': elements}, + } diff --git a/src/langbot/pkg/platform/adapters/officialaccount/adapter.py b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py index 124230aad..f4f362b4a 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/adapter.py +++ b/src/langbot/pkg/platform/adapters/officialaccount/adapter.py @@ -56,13 +56,14 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter. } if mode == 'drop': bot = OAClient(**common_kwargs) - elif mode == 'passive': + elif mode in ('passive', 'auto'): bot = OAClientForLongerResponse( **common_kwargs, LoadingMessage=config.get('LoadingMessage', ''), + customer_service=mode == 'auto', ) else: - raise KeyError('OfficialAccount Mode must be "drop" or "passive"') + raise KeyError('OfficialAccount Mode must be "drop", "passive" or "auto"') super().__init__( config=config, @@ -112,11 +113,12 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter. if not isinstance(source, OAEvent): raise ValueError('OfficialAccount reply_message requires an OAEvent source object') content = await OfficialAccountMessageConverter.yiri2target(message) - if self.config.get('Mode') == 'passive': - await self.bot.set_message(source.user_id, source.message_id, content) + if self.config.get('Mode') in ('passive', 'auto'): + queued = await self.bot.set_message(source.user_id, source.message_id, content) else: await self.bot.set_message(source.message_id, content) - return platform_events.MessageResult(message_id=source.message_id, raw={'queued': True}) + queued = True + return platform_events.MessageResult(message_id=source.message_id, raw={'queued': queued}) async def call_platform_api(self, action: str, params: dict = {}) -> dict: handler = PLATFORM_API_MAP.get(action) diff --git a/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml index 033c00811..2751023fd 100644 --- a/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/officialaccount/manifest.yaml @@ -34,6 +34,22 @@ spec: type: array[string] required: false default: [] + - name: AppID + label: + en_US: App ID + zh_Hans: 应用 ID + zh_Hant: 應用 ID + type: string + required: true + default: "" + - name: AppSecret + label: + en_US: App Secret + zh_Hans: 应用密钥 + zh_Hant: 應用密鑰 + type: string + required: true + default: "" - name: webhook_url label: en_US: Webhook Callback URL @@ -62,32 +78,32 @@ spec: type: string required: true default: "" - - name: AppID - label: - en_US: App ID - zh_Hans: 应用 ID - zh_Hant: 應用 ID - type: string - required: true - default: "" - - name: AppSecret - label: - en_US: App Secret - zh_Hans: 应用密钥 - zh_Hant: 應用密鑰 - type: string - required: true - default: "" - name: Mode label: en_US: Mode zh_Hans: 接入模式 zh_Hant: 接入模式 description: - en_US: "drop replies within the current callback; passive returns a loading message first and queues the real reply for the user's next message." - zh_Hans: "drop 会在当前回调内等待回复;passive 会先返回加载提示,并将真实回复排队到用户下一条消息。" - zh_Hant: "drop 會在目前回調內等待回覆;passive 會先回傳載入提示,並將真實回覆排隊到使用者下一則訊息。" - type: string + en_US: "Auto uses customer service messaging when available, falling back to next-message retrieval for permission, window or quota limits. Passive always waits for the next message. Drop requires a reply within 5 seconds." + zh_Hans: "自动模式在生成完成后通过客服接口回复;无权限、超出回复窗口或额度不足时,改为下一条消息领取。passive 始终由下一条消息领取;drop 需要在 5 秒内完成回复。客服接口通常需企业主体认证,以微信实际权限为准。" + zh_Hant: "自動模式在生成完成後透過客服介面回覆;無權限、超出回覆時限或額度不足時,改為下一則訊息領取。passive 始終由下一則訊息領取;drop 需要在 5 秒內完成回覆。客服介面通常需企業主體認證,以微信實際權限為準。" + type: select + options: + - name: auto + label: + en_US: Automatic reply with fallback (auto) + zh_Hans: 自动回复,受限时转为领取(auto) + zh_Hant: 自動回覆,受限時轉為領取(auto) + - name: drop + label: + en_US: Reply in current callback (drop) + zh_Hans: 当前回调内回复(drop) + zh_Hant: 目前回調內回覆(drop) + - name: passive + label: + en_US: Queue reply for next message (passive) + zh_Hans: 下一条消息时返回回复(passive) + zh_Hant: 下一則訊息時傳回回覆(passive) required: true default: "drop" - name: LoadingMessage diff --git a/src/langbot/pkg/platform/adapters/slack/adapter.py b/src/langbot/pkg/platform/adapters/slack/adapter.py index c2ee91a8d..81b1c94ca 100644 --- a/src/langbot/pkg/platform/adapters/slack/adapter.py +++ b/src/langbot/pkg/platform/adapters/slack/adapter.py @@ -8,6 +8,7 @@ import pydantic from langbot.libs.slack_api.api import SlackClient from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.libs.slack_api.media import send_chain from langbot.pkg.platform.adapters.slack.api_impl import SlackAPIMixin from langbot.pkg.platform.adapters.slack.errors import NotSupportedError from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter @@ -32,7 +33,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} - _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _message_cache: dict[tuple[str, str], platform_events.MessageReceivedEvent] = {} _user_cache: dict[str, platform_entities.User] = {} _group_cache: dict[str, platform_entities.UserGroup] = {} _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} @@ -41,14 +42,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - required_keys = ['bot_token', 'signing_secret'] + required_keys = ['bot_token', 'app_token' if config.get('socket_mode', False) else 'signing_secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise Exception(f'Slack Omni adapter missing config: {missing_keys}') bot = SlackClient( bot_token=config['bot_token'], - signing_secret=config['signing_secret'], + signing_secret=config.get('signing_secret', ''), logger=logger, unified_mode=True, ) @@ -73,6 +74,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap def get_supported_events(self) -> list[str]: return [ 'message.received', + 'message.edited', + 'message.deleted', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'group.info_updated', + 'bot.invited_to_group', + 'bot.removed_from_group', 'platform.specific', ] @@ -96,8 +105,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap target_id: str, message: platform_message.MessageChain, ) -> platform_events.MessageResult: - content = await SlackMessageConverter.yiri2target(message) - raw = await self._send_text(str(target_type), str(target_id), content) + raw = await send_chain(self.bot, self._normalize_target_type(str(target_type)), str(target_id), message) return platform_events.MessageResult(raw=raw) async def reply_message( @@ -111,8 +119,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap raise ValueError('Slack reply_message requires a SlackEvent source object') target_type = 'channel' if source.type == 'channel' else 'person' target_id = source.channel_id if source.type == 'channel' else source.user_id - raw = await self._send_text(target_type, target_id, await SlackMessageConverter.yiri2target(message)) - return platform_events.MessageResult(message_id=source.message_id, raw=raw) + thread_ts = source.get('event', {}).get('thread_ts') or (source.message_id if quote_origin else None) + if thread_ts: + target_type, target_id = 'channel', source.channel_id + raw = await send_chain(self.bot, target_type, target_id, message, thread_ts) + responses = raw.get('responses', []) + sent = responses[-1] if responses else {} + return platform_events.MessageResult( + message_id=sent.get('ts') or sent.get('message', {}).get('ts', ''), raw=raw + ) async def call_platform_api(self, action: str, params: dict = {}) -> dict: handler = PLATFORM_API_MAP.get(action) @@ -144,11 +159,18 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return await self.bot.handle_unified_webhook(request) async def run_async(self): + identity = await self.bot.client.auth_test() + self.bot_account_id = identity.get('user_id', '') + self.bot.bot_user_id = self.bot_account_id + if self.config.get('socket_mode', False): + await self.bot.run_socket(self.config['app_token']) + return await self.logger.info('Slack Omni adapter running in unified webhook mode') while True: await asyncio.sleep(1) async def kill(self) -> bool: + await self.bot.close_socket() self._message_cache.clear() self._user_cache.clear() self._group_cache.clear() @@ -159,11 +181,30 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return False def _register_native_handlers(self): - for msg_type in ('im', 'channel'): + for msg_type in ('im', 'channel', 'event'): self.bot.on_message(msg_type)(self._handle_native_event) async def _handle_native_event(self, event: SlackEvent): try: + self.event_converter.bot_user_id = getattr(self.bot, 'bot_user_id', None) or self.bot_account_id + raw = event.get('event', {}) + channel = raw.get('channel') or raw.get('item', {}).get('channel', '') + if ( + isinstance(channel, str) + and channel.startswith('D') + and channel not in self.event_converter.private_chats + and ( + raw.get('subtype') in {'message_changed', 'message_deleted'} + or raw.get('type') in {'reaction_added', 'reaction_removed'} + ) + ): + try: + conversation = await self.bot.client.conversations_info(channel=channel) + self.event_converter.remember_private_chat(channel, conversation.get('channel', {}).get('user', '')) + except Exception: + await self.logger.warning('Slack DM participant lookup failed; using channel ID for this event') + if event.get('event', {}).get('type') in {'app_uninstalled', 'tokens_revoked'}: + await self.logger.warning(f'Slack application lifecycle event: {event["event"]["type"]}') if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event) if legacy_event and type(legacy_event) in self.listeners: @@ -186,7 +227,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap def _cache_event(self, event: platform_events.Event): if not isinstance(event, platform_events.MessageReceivedEvent): return - self._message_cache[str(event.message_id)] = event + self._message_cache[(str(event.chat_id), str(event.message_id))] = event self._user_cache[str(event.sender.id)] = event.sender if event.group: self._group_cache[str(event.group.id)] = event.group diff --git a/src/langbot/pkg/platform/adapters/slack/api_impl.py b/src/langbot/pkg/platform/adapters/slack/api_impl.py index 500738b15..13fe35096 100644 --- a/src/langbot/pkg/platform/adapters/slack/api_impl.py +++ b/src/langbot/pkg/platform/adapters/slack/api_impl.py @@ -9,7 +9,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class SlackAPIMixin: - _message_cache: dict[str, platform_events.MessageReceivedEvent] + _message_cache: dict[tuple[str, str], platform_events.MessageReceivedEvent] _user_cache: dict[str, platform_entities.User] _group_cache: dict[str, platform_entities.UserGroup] _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] @@ -20,7 +20,7 @@ class SlackAPIMixin: chat_id: typing.Union[int, str], message_id: typing.Union[int, str], ) -> platform_events.MessageReceivedEvent: - event = self._message_cache.get(str(message_id)) + event = self._message_cache.get((str(chat_id), str(message_id))) if event is None: raise NotSupportedError('get_message:message_not_cached') return event diff --git a/src/langbot/pkg/platform/adapters/slack/event_converter.py b/src/langbot/pkg/platform/adapters/slack/event_converter.py index 6ebd48d91..2dae3491e 100644 --- a/src/langbot/pkg/platform/adapters/slack/event_converter.py +++ b/src/langbot/pkg/platform/adapters/slack/event_converter.py @@ -12,8 +12,16 @@ from langbot_plugin.api.entities.builtin.platform import events as platform_even class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): - def __init__(self, bot_token: str = ''): + def __init__(self, bot_token: str = '', bot_user_id: str = ''): self.bot_token = bot_token + self.bot_user_id = bot_user_id + self.private_chats: dict[str, str] = {} + + def remember_private_chat(self, channel_id: str, user_id: str): + if channel_id and user_id: + self.private_chats[channel_id] = user_id + while len(self.private_chats) > 4096: + self.private_chats.pop(next(iter(self.private_chats))) @staticmethod async def yiri2target(event: platform_events.Event) -> typing.Any: @@ -54,9 +62,93 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): ) async def target2yiri(self, event: SlackEvent) -> platform_events.Event: - if event.type in {'im', 'channel'}: + raw = event.get('event', {}) + kind = raw.get('type', '') + subtype = raw.get('subtype', '') + common = dict( + adapter_name=ADAPTER_NAME, + timestamp=_timestamp_value(event), + source_platform_object=SlackEvent(_public_payload(dict(event))), + ) + channel = raw.get('channel', '') or raw.get('item', {}).get('channel', '') + channel_info = channel if isinstance(channel, dict) else {'id': channel} + channel_id = channel_info.get('id', '') + group = platform_entities.UserGroup(id=channel_id, name=channel_info.get('name', channel_id)) + + def user(uid): + return platform_entities.User(id=uid, nickname=uid) if uid else None + + private = raw.get('channel_type') == 'im' or str(channel_id).startswith('D') + chat = dict( + chat_type=platform_entities.ChatType.PRIVATE if private else platform_entities.ChatType.GROUP, + chat_id=self.private_chats.get(channel_id, channel_id) if private else channel_id, + group=None if private else group, + ) + if kind == 'message' and subtype == 'message_changed': + message = raw.get('message', {}) + updated = SlackEvent({**event, 'event': {**raw, **message}}) + return platform_events.MessageEditedEvent( + **common, + **chat, + message_id=message.get('ts', ''), + editor=user(message.get('edited', {}).get('user') or message.get('user')) or user('unknown'), + new_content=await SlackMessageConverter.target2yiri(updated, self.bot_token), + ) + if kind == 'message' and subtype == 'message_deleted': + return platform_events.MessageDeletedEvent(**common, **chat, message_id=raw.get('deleted_ts', '')) + if kind in {'reaction_added', 'reaction_removed'} and raw.get('item', {}).get('type') == 'message': + return platform_events.MessageReactionEvent( + **common, + **chat, + message_id=raw['item'].get('ts', ''), + user=user(raw.get('user')) or user('unknown'), + reaction=raw.get('reaction', ''), + is_add=kind == 'reaction_added', + ) + if kind in {'member_joined_channel', 'member_left_channel'}: + member = user(raw.get('user')) or user('unknown') + inviter = user(raw.get('inviter')) + if self.bot_user_id and raw.get('user') == self.bot_user_id: + if kind == 'member_joined_channel' and inviter: + return platform_events.BotInvitedToGroupEvent(**common, group=group, inviter=inviter) + if kind == 'member_left_channel': + return platform_events.BotRemovedFromGroupEvent(**common, group=group) + if kind == 'member_joined_channel': + return platform_events.MemberJoinedEvent( + **common, + group=group, + member=member, + inviter=inviter, + join_type='invite' if inviter else 'direct', + ) + return platform_events.MemberLeftEvent(**common, group=group, member=member) + fields = { + 'channel_rename': 'name', + 'group_rename': 'name', + 'channel_archive': 'archived', + 'channel_unarchive': 'archived', + 'group_archive': 'archived', + 'group_unarchive': 'archived', + 'channel_topic': 'topic', + 'group_topic': 'topic', + 'channel_purpose': 'purpose', + 'group_purpose': 'purpose', + } + change = subtype if kind == 'message' else kind + if change in fields: + return platform_events.GroupInfoUpdatedEvent( + **common, + group=group, + operator=user(raw.get('user')), + changed_fields=[fields[change]], + ) + if ( + kind in {'message', 'app_mention'} + and subtype in {'', 'file_share', 'thread_broadcast'} + and event.type in {'im', 'channel'} + ): return await self.message_to_eba(event) - return self.platform_specific(event, f'slack.{event.type or "unknown"}') + return self.platform_specific(event, f'slack.{subtype or kind or event.get("type") or event.type or "unknown"}') async def message_to_eba(self, event: SlackEvent) -> platform_events.MessageReceivedEvent: sender_id = event.user_id or '' @@ -67,6 +159,8 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): chat_type = platform_entities.ChatType.PRIVATE chat_id = sender_id group = None + if event.type == 'im': + self.remember_private_chat(event.channel_id, sender_id) if event.type == 'channel': chat_type = platform_entities.ChatType.GROUP chat_id = event.channel_id or '' @@ -82,7 +176,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): chat_id=chat_id or '', group=group, timestamp=_timestamp_value(event), - source_platform_object=event, + source_platform_object=SlackEvent(_public_payload(dict(event))), ) @staticmethod @@ -91,15 +185,28 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): type='platform.specific', adapter_name=ADAPTER_NAME, action=action, - data=dict(event), + data=_public_payload(dict(event)), timestamp=_timestamp_value(event), - source_platform_object=event, + source_platform_object=SlackEvent(_public_payload(dict(event))), ) def _timestamp_value(event: SlackEvent) -> float: - raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts') + raw_ts = event.get('event', {}).get('event_ts') or event.get('event', {}).get('ts') or event.get('event_time') try: return float(raw_ts) except (TypeError, ValueError): return time.time() + + +def _public_payload(value): + """Keep callback credentials out of event data and monitoring records.""" + if isinstance(value, dict): + return { + key: _public_payload(item) + for key, item in value.items() + if key not in {'token', 'response_url', 'response_urls'} + } + if isinstance(value, list): + return [_public_payload(item) for item in value] + return value diff --git a/src/langbot/pkg/platform/adapters/slack/manifest.yaml b/src/langbot/pkg/platform/adapters/slack/manifest.yaml index b5163f55e..7f59b510d 100644 --- a/src/langbot/pkg/platform/adapters/slack/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/slack/manifest.yaml @@ -8,9 +8,9 @@ metadata: zh_Hans: Slack zh_Hant: Slack description: - en_US: Slack adapter with event-driven orchestration support, using LangBot's unified webhook endpoint. - zh_Hans: Slack 适配器,支持事件驱动编排,通过 LangBot 统一 Webhook 接收 Slack 事件订阅消息。 - zh_Hant: Slack 適配器,支援事件驅動編排,透過 LangBot 統一 Webhook 接收 Slack 事件訂閱訊息。 + en_US: Slack adapter with event-driven orchestration, supporting Webhook and Socket Mode. + zh_Hans: Slack 适配器,支持事件驱动编排,可使用 Webhook 或无需公网地址的长连接。 + zh_Hant: Slack 適配器,支援事件驅動編排,可使用 Webhook 或無需公開網址的長連線。 icon: slack.png spec: @@ -22,7 +22,26 @@ spec: en: https://langbot.app/docs/en/usage/platforms/slack ja: https://langbot.app/docs/ja/usage/platforms/slack config: + - name: quick_setup + label: {en_US: Quick Slack setup, zh_Hans: Slack 快速配置, zh_Hant: Slack 快速設定, ja_JP: Slack クイック設定} + description: {en_US: Create and configure an app using App Configuration Tokens., zh_Hans: 填入配置令牌,自动创建应用并配置权限,授权后填入凭据。, zh_Hant: 填入設定權杖,自動建立應用程式並設定權限,授權後填入憑證。, ja_JP: 設定トークンでアプリを作成し、承認後に認証情報を入力します。} + type: qr-code-login + login_platform: slack + required: false + - name: socket_mode + label: {en_US: Socket Mode, zh_Hans: 长连接模式, zh_Hant: 長連線模式, ja_JP: Socket Mode} + type: boolean + default: false + required: false + - name: app_token + label: {en_US: App-Level Token, zh_Hans: App-Level Token, zh_Hant: App-Level Token, ja_JP: App-Level Token} + description: {en_US: Generate an xapp- token with connections:write in Basic Information., zh_Hans: 在 Basic Information 中生成带 connections:write 权限的 xapp- 令牌。, zh_Hant: 在 Basic Information 產生具有 connections:write 權限的 xapp- 權杖。, ja_JP: Basic Information で connections:write 権限の xapp- トークンを生成してください。} + type: string + required: false + default: '' + show_if: {field: socket_mode, operator: eq, value: true} - name: webhook_url + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Webhook Callback URL zh_Hans: Webhook 回调地址 @@ -43,16 +62,25 @@ spec: required: true default: "" - name: signing_secret + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Signing Secret zh_Hans: 签名密钥 zh_Hant: 簽名密鑰 type: string - required: true + required: false default: "" supported_events: - message.received + - message.edited + - message.deleted + - message.reaction + - group.member_joined + - group.member_left + - group.info_updated + - bot.invited_to_group + - bot.removed_from_group - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/slack/message_converter.py b/src/langbot/pkg/platform/adapters/slack/message_converter.py index 07a246dae..20c6f82d4 100644 --- a/src/langbot/pkg/platform/adapters/slack/message_converter.py +++ b/src/langbot/pkg/platform/adapters/slack/message_converter.py @@ -4,6 +4,7 @@ import datetime from langbot.libs.slack_api.slackevent import SlackEvent from langbot.pkg.utils import image +from langbot.libs.slack_api.media import download_media import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot_plugin.api.entities.builtin.platform import message as platform_message @@ -55,15 +56,24 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter): if event.type == 'channel': components.append(platform_message.At(target='SlackBot')) - if event.pic_url: - try: + for file in event.get('event', {}).get('files', []): + url = file.get('url_private_download') or file.get('url_private') + if not url: + raise ValueError('Slack attachment is missing its download URL') + data, mime = await download_media(url, bot_token) + mime = file.get('mimetype') or mime + encoded = await image.encode_base64(data) + if mime.startswith('image/') and mime != 'image/svg+xml': + components.append(platform_message.Image(base64=f'data:{mime};base64,{encoded}')) + else: components.append( - platform_message.Image( - url=event.pic_url, base64=await image.get_slack_image_to_base64(event.pic_url, bot_token) + platform_message.File( + id=file.get('id', ''), + name=file.get('name') or file.get('title') or 'attachment', + size=len(data), + base64=encoded, ) ) - except Exception: - components.append(platform_message.Image(url=event.pic_url)) if event.text: components.append(platform_message.Plain(text=event.text)) diff --git a/src/langbot/pkg/platform/adapters/slack/platform_api.py b/src/langbot/pkg/platform/adapters/slack/platform_api.py index e1a80bf04..1e6d37d6c 100644 --- a/src/langbot/pkg/platform/adapters/slack/platform_api.py +++ b/src/langbot/pkg/platform/adapters/slack/platform_api.py @@ -5,7 +5,8 @@ import typing async def get_mode(adapter, params: dict) -> dict: return { - 'webhook': True, + 'webhook': not adapter.config.get('socket_mode', False), + 'socket_mode': adapter.config.get('socket_mode', False), 'bot_account_id': adapter.bot_account_id, } diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index 7debca097..5d6519267 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -149,7 +149,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo application.add_handler( MessageReactionHandler( telegram_callback, - MessageReactionHandler.MESSAGE_REACTION, + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION, ) ) @@ -213,6 +213,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo chat_id: int | str = int(chat_id_str) if chat_id_str.lstrip('-').isdigit() else chat_id_str message_thread_id = int(thread_id_str) if thread_id_str and thread_id_str.isdigit() else None + sent_messages = [] for component in components: component_type = component.get('type') args = {'chat_id': chat_id} @@ -225,20 +226,24 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo text = telegramify_markdown.markdownify(content=text) args['parse_mode'] = 'MarkdownV2' args['text'] = text - await self.bot.send_message(**args) + sent_messages.append(await self.bot.send_message(**args)) elif component_type == 'photo': photo = component.get('photo') if photo is None: continue - args['photo'] = telegram.InputFile(photo) - await self.bot.send_photo(**args) + sent_messages.append(await self._send_photo_or_document(photo, args)) elif component_type == 'document': doc = component.get('document') if doc is None: continue filename = component.get('filename', 'file') args['document'] = telegram.InputFile(doc, filename=filename) - await self.bot.send_document(**args) + sent_messages.append(await self.bot.send_document(**args)) + + return platform_events.MessageResult( + message_id=sent_messages[-1].message_id if sent_messages else None, + raw={'message_ids': [sent.message_id for sent in sent_messages]}, + ) async def reply_message( self, @@ -249,6 +254,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo assert isinstance(message_source.source_platform_object, Update) components = await TelegramMessageConverter.yiri2target(message, self.bot) + sent_messages = [] for component in components: component_type = component.get('type') args = { @@ -271,20 +277,34 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo if self.config['markdown_card'] is True: args['parse_mode'] = 'MarkdownV2' args['text'] = content - await self.bot.send_message(**args) + sent_messages.append(await self.bot.send_message(**args)) elif component_type == 'photo': photo = component.get('photo') if photo is None: continue - args['photo'] = telegram.InputFile(photo) - await self.bot.send_photo(**args) + sent_messages.append(await self._send_photo_or_document(photo, args)) elif component_type == 'document': doc = component.get('document') if doc is None: continue filename = component.get('filename', 'file') args['document'] = telegram.InputFile(doc, filename=filename) - await self.bot.send_document(**args) + sent_messages.append(await self.bot.send_document(**args)) + + return platform_events.MessageResult( + message_id=sent_messages[-1].message_id if sent_messages else None, + raw={'message_ids': [sent.message_id for sent in sent_messages]}, + ) + + async def _send_photo_or_document(self, photo: bytes, args: dict): + try: + return await self.bot.send_photo(photo=telegram.InputFile(photo), **args) + except telegram.error.BadRequest as exc: + if 'image_process_failed' not in str(exc).lower() and 'photo_invalid_dimensions' not in str(exc).lower(): + raise + # Preserve rejected image bytes without retrying already-sent parts. + await self.logger.warning('Telegram rejected photo encoding; sending the image as a document') + return await self.bot.send_document(document=telegram.InputFile(photo, filename='image'), **args) # ---- Streaming Output (preserving original logic) ---- @@ -303,6 +323,14 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo _MAX_STREAM_STATES: typing.ClassVar[int] = 1000 + async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None): + if chat_mode == 'private' or stream_id is None: + return + try: + await self.bot.delete_message(chat_id=chat_id, message_id=stream_id) + except telegram.error.TelegramError: + pass + def _cap_stream_states(self) -> None: while len(self.msg_stream_id) > self._MAX_STREAM_STATES: self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None) @@ -350,6 +378,15 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo if message_id not in self.msg_stream_id: return + # Media cannot be edited into a text stream. Deliver terminal attachments + # through the regular reply path, including attachment-only completions. + if is_final and not bot_message.tool_calls: + attachments = platform_message.MessageChain( + [part for part in message if isinstance(part, (platform_message.Image, platform_message.File))] + ) + if attachments: + await self.reply_message(message_source, attachments, quote_origin=quote_origin) + stream_state = self.msg_stream_id[message_id] chat_mode, stream_id = stream_state[:2] has_visible_content = len(stream_state) > 2 and stream_state[2] @@ -357,6 +394,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo if not components or components[0]['type'] != 'text': if is_final and bot_message.tool_calls is None: + if not has_visible_content: + await self._delete_group_stream_message(chat_mode, chat_id, stream_id) self.msg_stream_id.pop(message_id) return diff --git a/src/langbot/pkg/platform/adapters/telegram/event_converter.py b/src/langbot/pkg/platform/adapters/telegram/event_converter.py index 53a717c48..30baf66da 100644 --- a/src/langbot/pkg/platform/adapters/telegram/event_converter.py +++ b/src/langbot/pkg/platform/adapters/telegram/event_converter.py @@ -6,6 +6,7 @@ Converts all Telegram Update types to unified EBA events, not just messages. from __future__ import annotations import typing +import datetime import telegram from telegram import Update @@ -17,6 +18,13 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter +def _restriction_duration(until_date: datetime.datetime | None, event_date: datetime.datetime) -> int | None: + """Telegram uses Unix epoch zero for an indefinite restriction.""" + if until_date is None or until_date.timestamp() <= 0: + return None + return max(0, int(until_date.timestamp() - event_date.timestamp())) + + def _make_user(tg_user: telegram.User) -> platform_entities.User: """Convert a Telegram User to a unified User entity.""" return platform_entities.User( @@ -229,9 +237,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): if new_status == 'restricted' and cm.new_chat_member: restricted = cm.new_chat_member if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages: - duration = None - if hasattr(restricted, 'until_date') and restricted.until_date: - duration = int(restricted.until_date.timestamp() - time.time()) + duration = _restriction_duration(restricted.until_date, cm.date) return platform_events.MemberBannedEvent( type='group.member_banned', timestamp=cm.date.timestamp() if cm.date else time.time(), @@ -283,7 +289,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): ) # Bot removed from group - if old_status in ('member', 'administrator', 'creator') and new_status in ('left', 'kicked'): + if old_status in ('member', 'administrator', 'creator', 'restricted') and new_status in ('left', 'kicked'): return platform_events.BotRemovedFromGroupEvent( type='bot.removed_from_group', timestamp=mcm.date.timestamp() if mcm.date else time.time(), @@ -297,9 +303,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): if new_status == 'restricted' and mcm.new_chat_member: restricted = mcm.new_chat_member if hasattr(restricted, 'can_send_messages') and not restricted.can_send_messages: - duration = None - if hasattr(restricted, 'until_date') and restricted.until_date: - duration = int(restricted.until_date.timestamp() - time.time()) + duration = _restriction_duration(restricted.until_date, mcm.date) return platform_events.BotMutedEvent( type='bot.muted', timestamp=mcm.date.timestamp() if mcm.date else time.time(), @@ -310,7 +314,11 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=update, ) - if old_status == 'restricted' and new_status in ('member', 'administrator') and mcm.new_chat_member: + was_muted = old_status == 'restricted' and not mcm.old_chat_member.can_send_messages + can_send = new_status in ('member', 'administrator') or ( + new_status == 'restricted' and mcm.new_chat_member.can_send_messages + ) + if was_muted and can_send: return platform_events.BotUnmutedEvent( type='bot.unmuted', timestamp=mcm.date.timestamp() if mcm.date else time.time(), @@ -341,14 +349,18 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): reaction = update.message_reaction chat = reaction.chat - # Extract newly added emojis - new_emojis = [] - if reaction.new_reaction: - for r in reaction.new_reaction: - if hasattr(r, 'emoji'): - new_emojis.append(r.emoji) - elif hasattr(r, 'custom_emoji_id'): - new_emojis.append(str(r.custom_emoji_id)) + def emojis(items): + return [ + str(r.emoji if hasattr(r, 'emoji') else r.custom_emoji_id) + for r in items + if hasattr(r, 'emoji') or hasattr(r, 'custom_emoji_id') + ] + + old_emojis = emojis(reaction.old_reaction or []) + new_emojis = emojis(reaction.new_reaction or []) + added = [emoji for emoji in new_emojis if emoji not in old_emojis] + removed = [emoji for emoji in old_emojis if emoji not in new_emojis] + changed = added or removed user = platform_entities.User(id='') if reaction.user: @@ -363,8 +375,8 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): adapter_name='telegram-omni', message_id=reaction.message_id, user=user, - reaction=new_emojis[0] if new_emojis else '', - is_add=len(new_emojis) > 0, + reaction=changed[0] if changed else '', + is_add=bool(added), chat_type=ct, chat_id=chat.id, group=group, diff --git a/src/langbot/pkg/platform/adapters/wecom/manifest.yaml b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml index fd63e8348..a5b11284e 100644 --- a/src/langbot/pkg/platform/adapters/wecom/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecom/manifest.yaml @@ -8,9 +8,9 @@ metadata: zh_Hans: 企业微信 zh_Hant: 企業微信 description: - en_US: WeCom application message adapter with event-driven orchestration support - zh_Hans: 企业微信内部应用消息适配器,支持事件驱动编排 - zh_Hant: 企業微信內部應用訊息適配器,支援事件驅動編排 + en_US: Serve employees through a custom WeCom app + zh_Hans: 通过企业自建应用,为内部员工提供服务 + zh_Hant: 透過企業自建應用,為內部員工提供服務 icon: wecom.png spec: diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py index 38fb5e8df..90ee727e7 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter import asyncio +import base64 import time import traceback import typing @@ -47,6 +48,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo _group_cache: dict[str, platform_entities.UserGroup] = {} _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} _stream_to_monitoring_msg: dict[str, tuple[str, float]] = {} + _business_event_ids: dict[tuple[str, str], float] = {} _STREAM_MAPPING_TTL: int = 600 class Config: @@ -103,6 +105,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo return [ 'message.received', 'feedback.received', + 'wecombot.enter_chat', + 'wecombot.template_card_event', 'platform.specific', ] @@ -131,7 +135,29 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo _join_text_components = staticmethod(LegacyWecomBotAdapter._join_text_components) _iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components) - _send_media = staticmethod(LegacyWecomBotAdapter._send_media) + + @staticmethod + async def _send_media(bot, req_id: str, item: dict) -> bool: + """Require upload and delivery acknowledgements instead of dropping attachments.""" + kind = item['type'] + payload = item.get('base64') or '' + if payload.startswith('data:'): + payload = payload.split(',', 1)[-1] + try: + data = base64.b64decode(payload, validate=True) + except ValueError as exc: + raise ValueError(f'Invalid {kind} attachment data') from exc + if not data: + raise ValueError(f'{kind} attachment has no data to upload') + result = await bot.upload_media(data, item.get('name') or f'attachment.{kind}', media_type=kind) + media_id = result.get('media_id') if isinstance(result, dict) else None + if not media_id: + raise RuntimeError(f'WeCom {kind} upload failed: no media ID returned') + ack = await getattr(bot, f'reply_{kind}')(req_id, media_id) + if ack is None or ack.get('errcode', 0) != 0: + code = ack.get('errcode') if isinstance(ack, dict) else 'no acknowledgement' + raise RuntimeError(f'WeCom {kind} delivery failed: {code}') + return True async def send_message( self, @@ -145,9 +171,37 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo raise NotSupportedError(f'send_message:{target_type}') items = await WecomBotMessageConverter.yiri2target(message) content = self._join_text_components(items) + if list(self._iter_media_components(items)): + raise NotSupportedError('send_message:media requires an originating message callback') raw = await self.bot.send_message(str(target_id), content) return platform_events.MessageResult(raw={'result': raw}) + async def reply_to_event( + self, *, context: dict, target_type: str, target_id: str, message: platform_message.MessageChain + ) -> platform_events.MessageResult: + """Bind Agent replies to the originating callback and its loading stream.""" + message_id = ((context.get('delivery') or {}).get('reply_target') or {}).get('message_id') + if context.get('event_type') == 'message.received' and message_id: + source = await self.get_message(target_type, target_id, message_id) + return await self.reply_message(source, message) + if context.get('event_type') == 'wecombot.enter_chat': + data = context.get('data') or {} + raw_event = data.get('data') or {} + if time.time() - float(data.get('timestamp') or 0) >= 5: + raise ValueError('WeCom welcome replies must complete within 5 seconds of entering the chat') + items = await WecomBotMessageConverter.yiri2target(message) + if list(self._iter_media_components(items)): + raise NotSupportedError('welcome_reply:media') + content = self._join_text_components(items) + callback_id = data.get('message_id') if self.config.get('enable-webhook') else raw_event.get('req_id') + if not callback_id: + raise ValueError('WeCom welcome reply is missing its callback ID') + raw = await self.bot.reply_welcome(callback_id, content) + if raw is None: + raise RuntimeError('WeCom welcome reply was not acknowledged') + return platform_events.MessageResult(raw={'result': raw}) + return await self.send_message(target_type, target_id, message) + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -158,11 +212,18 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message requires a WecomBotEvent source object') items = await WecomBotMessageConverter.yiri2target(message) + if self.config.get('enable-webhook') and list(self._iter_media_components(items)): + raise NotSupportedError('reply_media:webhook_mode; use WebSocket mode to deliver files') content = self._join_text_components(items) raw = None + from .stream_text import format_stream_text + + content = format_stream_text(content) if not self.config.get('enable-webhook', False) and event.get('req_id'): if content: - raw = await self.bot.reply_text(event.get('req_id'), content) + finished = await self.bot.push_stream_chunk(event.message_id, content, is_final=True) + if not finished: + raw = await self.bot.reply_text(event.get('req_id'), content) for item in self._iter_media_components(items): await self._send_media(self.bot, event.get('req_id'), item) else: @@ -181,7 +242,11 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object') items = await WecomBotMessageConverter.yiri2target(message) - content = self._join_text_components(items) + if is_final and self.config.get('enable-webhook') and list(self._iter_media_components(items)): + raise NotSupportedError('reply_media:webhook_mode; use WebSocket mode to deliver files') + from .stream_text import format_stream_text + + content = format_stream_text(self._join_text_components(items)) success = await self.bot.push_stream_chunk(event.message_id, content, is_final=is_final) if not success and is_final and not self.config.get('enable-webhook', False) and event.get('req_id'): await self.bot.reply_text(event.get('req_id'), content) @@ -276,10 +341,13 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo interaction_event = interaction_event_from_native(event) if interaction_event is not None: await self._dispatch_eba_event(interaction_event) + else: + await self._handle_native_event(event) except Exception: await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}') async def _handle_native_event(self, event: WecomBotEvent): + key = None try: if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event) @@ -288,9 +356,24 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo eba_event = await self.event_converter.target2yiri(event) if eba_event: + if eba_event.type in {'wecombot.enter_chat', 'wecombot.template_card_event'}: + message_id = eba_event.message_id + now = time.monotonic() + self._business_event_ids = { + key: seen for key, seen in self._business_event_ids.items() if now - seen < 600 + } + key = (eba_event.type, message_id) + if message_id and key in self._business_event_ids: + return + if message_id: + self._business_event_ids[key] = now + if len(self._business_event_ids) > 2048: + self._business_event_ids.pop(next(iter(self._business_event_ids))) self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) except Exception: + if key is not None: + self._business_event_ids.pop(key, None) await self.logger.error(f'Error in wecombot native event: {traceback.format_exc()}') async def _handle_feedback(self, **kwargs): diff --git a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py index 31fefb559..a99508813 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py @@ -50,6 +50,36 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): ) async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event: + from langbot.libs.wecom_ai_bot_api.api import ( + extract_wecom_event_type, + extract_template_card_event_payload, + extract_template_card_action, + ) + + event_type = extract_wecom_event_type(event) or event.get('eventtype') + if event_type in {'enter_chat', 'template_card_event'}: + group = event.get('chattype', event.type) == 'group' + fields = dict( + adapter_name=ADAPTER_NAME, + user=platform_entities.User(id=event.userid, nickname=event.username), + chat_id=str(event.chatid if group else event.userid), + chat_type=platform_entities.ChatType.GROUP if group else platform_entities.ChatType.PRIVATE, + message_id=event.message_id or '', + timestamp=float(event.get('create_time') or time.time()), + data=dict(event), + source_platform_object=event, + ) + if event_type == 'enter_chat': + return platform_events.WecomBotEnterChatEvent(**fields) + card = extract_template_card_event_payload(event) + task_id, event_key, card_type = extract_template_card_action(card) + return platform_events.WecomBotTemplateCardEvent( + **fields, + task_id=task_id, + event_key=event_key, + card_type=card_type, + selected_items=card.get('selected_items') or card.get('SelectedItems') or [], + ) if event.type in {'single', 'group'} and event.msgtype != 'event': return await self.message_to_eba(event) return self.platform_specific( diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml index 2b722142f..d5b7c1eeb 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -8,9 +8,9 @@ metadata: zh_Hans: 企业微信智能机器人 zh_Hant: 企業微信智慧機器人 description: - en_US: WeCom AI Bot adapter with event-driven orchestration support - zh_Hans: 企业微信智能机器人适配器,支持事件驱动编排、长连接和 Webhook 两种接入方式 - zh_Hant: 企業微信智慧機器人適配器,支援事件驅動編排、長連線和 Webhook 兩種接入方式 + en_US: Answer questions and assist teams in WeCom chats and groups + zh_Hans: 在企业微信单聊和群聊中提供智能问答与协作 + zh_Hant: 在企業微信單聊和群聊中提供智慧問答與協作 icon: wecombot.png spec: @@ -27,10 +27,9 @@ spec: zh_Hans: 一键创建机器人 zh_Hant: 一鍵建立機器人 description: - en_US: 'Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to - be filled in manually.' - zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。 - zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。 + en_US: Scan with WeCom to create a bot and fill in BotId and Secret automatically. + zh_Hans: 使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。 + zh_Hant: 使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。 type: qr-code-login login_platform: wecombot required: false @@ -60,8 +59,12 @@ spec: en_US: Robot Name zh_Hans: 机器人名称 zh_Hant: 機器人名稱 + description: + en_US: Optional. Only removes matching @name text in group messages; bot mentions are identified by BotId. + zh_Hans: 可选。仅用于清理群消息中的 @名称 文本,机器人提及通过 BotId 识别。 + zh_Hant: 選填。僅用於清理群訊息中的 @名稱 文字,機器人提及透過 BotId 識別。 type: string - required: true + required: false default: "" - name: enable-webhook label: @@ -103,6 +106,10 @@ spec: type: string required: false default: "" + show_if: + field: enable-webhook + operator: neq + value: true - name: Corpid label: en_US: Corpid @@ -115,6 +122,10 @@ spec: type: string required: false default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: Token label: en_US: Token @@ -127,18 +138,26 @@ spec: type: string required: false default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: EncodingAESKey label: en_US: EncodingAESKey zh_Hans: 消息加解密密钥 (EncodingAESKey) zh_Hant: 訊息加解密密鑰 (EncodingAESKey) description: - en_US: Required for webhook mode. Optional for WebSocket mode when encrypted files need to be decrypted. - zh_Hans: Webhook 模式必填。WebSocket 模式下如需解密文件则填写。 - zh_Hant: Webhook 模式必填。WebSocket 模式下如需解密檔案則填寫。 + en_US: Required for webhook mode. Long connections use the decryption key included in each message. + zh_Hans: Webhook 模式必填。长连接模式使用消息自带的解密密钥。 + zh_Hant: Webhook 模式必填。長連線模式使用訊息自帶的解密金鑰。 type: string required: false default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: enable-stream-reply label: en_US: Enable Stream Reply @@ -155,6 +174,8 @@ spec: supported_events: - message.received - feedback.received + - wecombot.enter_chat + - wecombot.template_card_event - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/wecombot/message_converter.py b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py index fca3e1f5d..95ce52202 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/message_converter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/message_converter.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import re from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -46,12 +47,14 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte platform_message.Source(id=event.message_id, time=datetime.datetime.now()), ] if event.type == 'group' and event.ai_bot_id: + # Group callbacks identify the mentioned bot by ID, independently + # of its display name or the optional text-cleanup configuration. components.append(platform_message.At(target=event.ai_bot_id)) if event.content: content = event.content - if bot_name: - content = content.replace(f'@{bot_name}', '').strip() + if event.type == 'group' and bot_name: + content = re.sub(rf'(? str: + if content.strip().lower() in {'<', ')', content, flags=re.IGNORECASE) + thoughts: list[str] = [] + answers: list[str] = [] + thinking = False + for token in tokens: + if re.fullmatch(r'', token, flags=re.IGNORECASE): + thinking = True + elif re.fullmatch(r'', token, flags=re.IGNORECASE): + thinking = False + elif token.strip(): + (thoughts if thinking else answers).append(token.strip()) + if not thoughts: + return '\n\n'.join(answers) + reasoning = '\n\n'.join(thoughts) + answer = '\n\n'.join(answers) + if thinking and not answer: + return f'\n{reasoning}' + return f'\n{reasoning}\n' + (f'\n\n{answer}' if answer else '') diff --git a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py index 2fb12ef7f..f82d1b58a 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/adapter.py @@ -79,6 +79,12 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform def get_supported_events(self) -> list[str]: return [ 'message.received', + 'message.deleted', + 'wecomcs.enter_session', + 'wecomcs.msg_send_fail', + 'wecomcs.servicer_status_change', + 'wecomcs.session_status_change', + 'wecomcs.reject_customer_msg_switch_change', 'platform.specific', ] @@ -110,6 +116,36 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform ) return platform_events.MessageResult(raw={'results': raw_results}) + async def reply_to_event( + self, *, context: dict, target_type: str, target_id: str, message: platform_message.MessageChain + ) -> platform_events.MessageResult: + """Use event credentials for welcome and session-status replies.""" + event_type = context.get('event_type') + if event_type not in ('wecomcs.enter_session', 'wecomcs.session_status_change'): + return await self.send_message(target_type, target_id, message) + data = context.get('data') or {} + code_key = 'welcome_code' if event_type == 'wecomcs.enter_session' else 'msg_code' + code = data.get(code_key) or (data.get('data') or {}).get(code_key) + if not code: + raise ValueError( + f'WeCom CS did not provide {code_key} for this event; an event response is not available. ' + 'Entering a session does not open the ordinary messaging window. ' + 'Ask the customer to send a message before sending an ordinary reply.' + ) + timestamp = data.get('timestamp') + if event_type == 'wecomcs.enter_session' and timestamp and time.time() - float(timestamp) >= 20: + raise ValueError( + 'WeCom CS welcome reply expired: welcome_code is valid for only 20 seconds after entry. ' + 'Use a fast, fixed welcome response; do not wait for a long AI run or retry this event.' + ) + if not message.root or any(not isinstance(item, platform_message.Plain) for item in message.root): + raise ValueError('WeCom CS event replies currently support a single text message only') + text = ''.join(item.text for item in message.root) + if not text.strip(): + raise ValueError('WeCom CS event reply text must not be empty') + result = await self.bot.send_event_text(str(code), self._make_outbound_msgid(), text) + return platform_events.MessageResult(message_id=result.get('msgid'), raw=result) + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -183,7 +219,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform async def on_message(event: WecomCSEvent): await self._handle_native_event(event) - for msg_type in ('text', 'image', 'file', 'voice'): + for msg_type in ('text', 'image', 'file', 'voice', 'event'): self.bot.on_message(msg_type)(on_message) async def _handle_native_event(self, event: WecomCSEvent): diff --git a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py index 3dc56c68e..4c3d76eb2 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecomcs/event_converter.py @@ -29,6 +29,41 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter): async def target2yiri(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.Event | None: if event.type in {'text', 'image', 'file', 'voice'}: return await WecomCSEventConverter.message_to_eba(event, bot) + if event.type == 'event': + data = event.get('event') or {} + event_type = data.get('event_type', 'unknown') + customer_id = data.get('external_userid') or '' + common = { + 'adapter_name': ADAPTER_NAME, + 'timestamp': float(event.timestamp or 0), + 'source_platform_object': event, + 'chat_id': make_private_chat_id(customer_id, data.get('open_kfid')) if customer_id else '', + } + if event_type in {'user_recall_msg', 'servicer_recall_msg'}: + operator_id = data.get('servicer_userid') if event_type == 'servicer_recall_msg' else customer_id + return platform_events.MessageDeletedEvent( + message_id=data.get('recall_msgid') or '', + operator=platform_entities.User(id=operator_id) if operator_id else None, + **common, + ) + event_classes = { + 'enter_session': platform_events.WecomCSEnterSessionEvent, + 'msg_send_fail': platform_events.WecomCSMessageSendFailedEvent, + 'servicer_status_change': platform_events.WecomCSServicerStatusChangedEvent, + 'session_status_change': platform_events.WecomCSSessionStatusChangedEvent, + 'reject_customer_msg_switch_change': platform_events.WecomCSRejectCustomerMessageChangedEvent, + } + event_class = event_classes.get(event_type) + if event_class: + fields = { + key: value for key, value in data.items() if key in event_class.model_fields and value is not None + } + fields.update(common) + fields['data'] = dict(data) + actor_id = data.get('servicer_userid') or customer_id + fields['user'] = platform_entities.User(id=actor_id) if actor_id else None + return event_class(**fields) + return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event_type}') return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}') @staticmethod diff --git a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml index d1f04c7cc..3c8821675 100644 --- a/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecomcs/manifest.yaml @@ -8,9 +8,9 @@ metadata: zh_Hans: 企业微信客服 zh_Hant: 企業微信客服 description: - en_US: WeCom Customer Service adapter with event-driven orchestration support - zh_Hans: 企业微信客服适配器,支持事件驱动编排,通过统一 Webhook 接收客服会话消息 - zh_Hant: 企業微信客服適配器,支援事件驅動編排,透過統一 Webhook 接收客服會話訊息 + en_US: Support customers on WeChat through WeChat Customer Service + zh_Hans: 通过微信客服,为微信用户提供咨询与售后服务 + zh_Hant: 透過微信客服,為微信使用者提供諮詢與售後服務 icon: wecom.png spec: @@ -93,6 +93,12 @@ spec: supported_events: - message.received + - message.deleted + - wecomcs.enter_session + - wecomcs.msg_send_fail + - wecomcs.servicer_status_change + - wecomcs.session_status_change + - wecomcs.reject_customer_msg_switch_change - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 7b942c54d..ac425a9c4 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -1,9 +1,15 @@ from __future__ import annotations +from ..cloud.plan_policy import require_workspace_platform + +from ..i18n import language_scope +from ..i18n.workspace import get_workspace_language + import asyncio import contextlib import dataclasses import functools +import hashlib import json import re import time @@ -85,6 +91,7 @@ class RuntimeBot: ): if not isinstance(execution_context, ExecutionContext): raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext') + self._ingress_dedup_lock = asyncio.Lock() if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip(): raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace') if execution_context.placement_generation <= 0: @@ -112,6 +119,7 @@ class RuntimeBot: async def assert_execution_active(self) -> None: """Fail closed when this long-lived adapter belongs to a stale placement.""" + await require_workspace_platform(self.ap, self.workspace_uuid, self.bot_entity.adapter) await self.ap.workspace_service.get_execution_binding( self.workspace_uuid, expected_generation=self.placement_generation, @@ -158,7 +166,7 @@ class RuntimeBot: @classmethod def _is_message_event_type(cls, event_type: str) -> bool: - return cls._match_event_pattern(event_type, 'message.*') + return event_type == 'message.received' @classmethod def _agent_supports_event_type( @@ -310,6 +318,12 @@ class RuntimeBot: step['reason'] = 'Event type does not match binding event_pattern' diagnostic_steps.append(step) continue + if binding.get('target_type') == 'pipeline' and not cls._is_message_event_type(event_type): + step['failure_code'] = 'processor_incompatible' + step['reason'] = 'Pipeline targets only support message.received' + diagnostic_steps.append(step) + continue + if not cls._match_event_filters(event, binding.get('filters')): step['failure_code'] = 'filters_mismatch' step['reason'] = 'Event data does not satisfy binding filters' @@ -396,10 +410,15 @@ class RuntimeBot: } log_method = getattr(self.logger, level, self.logger.info) await log_method(text, metadata=metadata) - if status in {'delivered', 'failed', 'discarded', 'not_matched'}: + if status in {'delivered', 'failed'}: from ..telemetry import trace as trace_mod from ..telemetry.execution import record + state = trace_mod.current() + if status == 'failed' and state is not None and state.reportable and not trace_mod.reporting_suppressed(): + # The root event can finish successfully after a delivery failed. + # Preserve that failure for closure reporting in failures mode. + state.mark_failure(failure_code or 'route_failed') with trace_mod.scope(route_ref=self._route_ref(binding, target_type, target_uuid)): # The routing decision is its own workflow step; the Pipeline or # processor it delivers to hangs under this node. @@ -418,6 +437,8 @@ class RuntimeBot: def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None: """Return the first Pipeline target configured for an event type.""" + if not self._is_message_event_type(event_type): + return None matched: list[tuple[int, int, str]] = [] for index, binding in enumerate(self._get_event_bindings()): if not binding.get('enabled', True): @@ -445,10 +466,13 @@ class RuntimeBot: return {} if hasattr(model, 'model_dump'): try: - return model.model_dump(mode='json') + # Native adapter objects are for replying, not event logging. + # Exclude them before JSON serialization, since some (OAEvent, + # for example) cannot be serialized by Pydantic. + return model.model_dump(mode='json', exclude={'source_platform_object'}) except TypeError: try: - return model.model_dump() + return model.model_dump(exclude={'source_platform_object'}) except Exception: return {} except Exception: @@ -616,7 +640,6 @@ class RuntimeBot: group_id = cls._get_entity_id(group) if group_id: metadata['group_id'] = group_id - return 'group', group_id, metadata chat_id = getattr(event, 'chat_id', None) chat_type = getattr(event, 'chat_type', None) @@ -627,6 +650,11 @@ class RuntimeBot: return 'group', str(chat_id), metadata return 'person', str(chat_id), metadata + # A guild can contain multiple delivery channels. Keep its id for + # membership APIs, but prefer the event's explicit chat for replies. + if group_id: + return 'group', group_id, metadata + session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None)) if session_target_type and session_target_id: return session_target_type, session_target_id, metadata @@ -699,33 +727,22 @@ class RuntimeBot: @classmethod def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput: - text = None + from ..agent.runner.query_entry_adapter import QueryEntryAdapter + + text = platform_events.event_summary(event) contents: list[dict[str, typing.Any]] = [] message_chain = getattr(event, 'message_chain', None) if message_chain: - text_parts: list[str] = [] try: for component in message_chain: - if isinstance(component, platform_message.Plain): - text_parts.append(component.text) - elif isinstance(component, platform_message.Image): + if isinstance(component, platform_message.Image): if component.url: contents.append({'type': 'image_url', 'image_url': {'url': component.url}}) elif component.base64: contents.append({'type': 'image_base64', 'image_base64': component.base64}) except TypeError: - text_parts.append(str(message_chain)) - text = ''.join(text_parts) or str(message_chain) - - if text is None: - feedback_content = getattr(event, 'feedback_content', None) - if feedback_content: - text = str(feedback_content) - elif getattr(event, 'action', None): - text = str(getattr(event, 'action')) - else: - text = str(getattr(event, 'type', 'event')) + pass if text: contents.insert(0, {'type': 'text', 'text': text}) @@ -733,7 +750,7 @@ class RuntimeBot: return AgentInput( text=text, contents=contents, - attachments=[], + attachments=QueryEntryAdapter._build_attachments(event, contents), ) def _eba_event_to_agent_envelope( @@ -885,6 +902,18 @@ class RuntimeBot: @staticmethod def _platform_event_raw_id(event: platform_events.EBAEvent) -> str: event_type = getattr(event, 'type', None) or event.__class__.__name__ + if isinstance(event, platform_events.MessageDeletedEvent) and event.message_id: + # A recall is distinct from the original message. + return f'{event_type}:{event.message_id}' + if getattr(event, 'adapter_name', None) == 'wecomcs-omni': + source = getattr(event, 'source_platform_object', None) + if isinstance(source, dict): + if source.get('msgid'): + return str(source['msgid']) + # Native service events can lack msgid. Preserve their identity + # across sync retries and process restarts instead of using UUIDs. + payload = json.dumps(source, sort_keys=True, ensure_ascii=False, separators=(',', ':')) + return f'{event_type}:{hashlib.sha256(payload.encode()).hexdigest()}' return str( getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}' ) @@ -899,9 +928,26 @@ class RuntimeBot: from ..telemetry import trace as trace_mod from ..telemetry.execution import ingress + await self.assert_execution_active() event.bot_uuid = self.bot_entity.uuid execution_id = self._platform_event_execution_id(event) - with ingress(getattr(self, 'ap', None), 'event_done', getattr(self, 'execution_context', None), execution_id): + if getattr(event, 'adapter_name', None) == 'wecomcs-omni': + from ..agent.runner.event_log_store import EventLogStore + + # The sync API can replay history after an adapter restart. Reuse + # the durable ingress journal, including records from earlier runs. + async with self._ingress_dedup_lock: + store = EventLogStore(self.ap.persistence_mgr.get_db_engine()) + if await store.get_event(execution_id): + return + await self._persist_monitoring_ingress(event, adapter, execution_id) + with ingress( + getattr(self, 'ap', None), + 'event_done', + getattr(self, 'execution_context', None), + execution_id, + require_route=True, + ): # The inbound event is the root workflow step: everything recorded # while handling it hangs under this node. with trace_mod.stage_scope() as node: @@ -931,6 +977,34 @@ class RuntimeBot: node=node, ) + async def _persist_monitoring_ingress(self, event, adapter, execution_id, status='running', routes=None): + service = getattr(getattr(self, 'ap', None), 'monitoring_service', None) + if service is None or not execution_id: + return + try: + sender = getattr(event, 'sender', None) or getattr(event, 'user', None) or getattr(event, 'operator', None) + target_type, target_id, _ = self._infer_reply_target(event) + await service.record_ingress_event( + self.execution_context, + event_id=execution_id, + event_type=getattr(event, 'type', None) or event.__class__.__name__, + bot_id=self.bot_entity.uuid, + bot_name=self.bot_entity.name, + platform=getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__, + payload={ + key: value + for key, value in self._safe_model_dump(event).items() + if key != 'source_platform_object' and not key.startswith('_') + }, + actor_id=self._get_entity_id(sender), + actor_name=self._get_entity_name(sender), + conversation_id=f'{target_type}_{target_id}' if target_type and target_id else None, + status=status, + routes=routes, + ) + except Exception as exc: + await self.logger.warning(f'Failed to persist monitoring ingress: {exc}') + async def _handle_platform_event_body( self, event: platform_events.EBAEvent, @@ -941,26 +1015,32 @@ class RuntimeBot: results: list[typing.Any] = [] try: await self._record_adapter_event(event, adapter) + await self._persist_monitoring_ingress(event, adapter, execution_id) - primary = ( - self._handle_interaction_submission(event, adapter, execution_id) + primary_handler = ( + self._handle_interaction_submission if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted' - else self._dispatch_eba_event_to_processor(event, adapter, execution_id=execution_id) + else self._dispatch_eba_event_to_processor ) subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', [])) seen = set() - tasks = [primary] + tasks = [self._run_event_delivery(primary_handler, event, adapter, execution_id=execution_id)] for subscription in subscriptions: processor_uuid = subscription.get('processor_uuid') if not processor_uuid or processor_uuid in seen or not subscription.get('enabled', True): continue seen.add(processor_uuid) - tasks.append(self._dispatch_plugin_subscription(event, adapter, processor_uuid, execution_id)) + tasks.append( + self._run_event_delivery( + self._dispatch_plugin_subscription, event, adapter, processor_uuid, execution_id + ) + ) # Start all deliveries together. A slow or failed subscriber cannot block # another subscriber or the primary route from receiving the event. results = await asyncio.gather(*tasks, return_exceptions=True) except BaseException as exc: self._record_platform_event(event, adapter, 'failed', f'{type(exc).__name__}: {exc}', node) + await self._persist_monitoring_ingress(event, adapter, execution_id, 'failed', [exc]) raise # Recorded after handling so the inbound stage carries the real outcome @@ -973,6 +1053,25 @@ class RuntimeBot: except Exception: pass self._record_platform_event(event, adapter, outcome, error_detail, node) + await self._persist_monitoring_ingress( + event, + adapter, + execution_id, + {'success': 'completed', 'skipped': 'ignored'}.get(outcome, 'failed'), + results, + ) + + async def _run_event_delivery(self, handler, *args, **kwargs): + # gather creates child tasks. Each delivery needs its own database scope; + # copying the listener's ContextVar does not transfer task ownership. + persistence = getattr(getattr(self, 'ap', None), 'persistence_mgr', None) + if getattr(getattr(persistence, 'mode', None), 'value', None) == 'cloud_runtime': + tenant_scope = getattr(persistence, 'tenant_scope', None) + if not callable(tenant_scope): + raise RuntimeError('Cloud event deliveries require a tenant scope') + async with tenant_scope(self.workspace_uuid): + return await handler(*args, **kwargs) + return await handler(*args, **kwargs) async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid, execution_id=None): event_type = event.type @@ -993,22 +1092,27 @@ class RuntimeBot: patterns = descriptor.supported_event_patterns if not patterns or not self._agent_supports_event_type(patterns, event_type): return - agent = {**agent, 'supported_event_patterns': patterns} - return await self._dispatch_eba_event_to_processor( - event, adapter, event_binding, agent, execution_id=execution_id - ) except Exception as exc: - return await self._record_event_route_trace( - event_type=event_type, - status='failed', - level='error', - binding=event_binding, - target_type='event_processor', - target_uuid=processor_uuid, - failure_code='runner_failed', - reason=str(exc), - text=f'Plugin processor {processor_uuid} failed: {exc}', - ) + from ..telemetry import trace as trace_mod + + # No matching declaration was resolved: log locally without + # reporting a failed delivery for every unrelated incoming event. + with trace_mod.suppress_reporting(): + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type='event_processor', + target_uuid=processor_uuid, + failure_code='runner_failed', + reason=str(exc), + text=f'Plugin processor {processor_uuid} failed: {exc}', + ) + agent = {**agent, 'supported_event_patterns': patterns} + return await self._dispatch_eba_event_to_processor( + event, adapter, event_binding, agent, execution_id=execution_id + ) async def _dispatch_eba_event_to_processor( self, @@ -1032,6 +1136,12 @@ class RuntimeBot: ) target_type = event_binding.get('target_type') + from ..telemetry import trace as trace_mod + + if target_type != 'discard': + # Matching precedes target lookup/execution, so genuine delivery + # failures remain observable even when the target is unavailable. + trace_mod.admit() await self._record_event_route_trace( event_type=event_type, status='matched', @@ -1044,7 +1154,7 @@ class RuntimeBot: if isinstance(event, platform_events.MessageReceivedEvent): from ..telemetry import trace as trace_mod - with trace_mod.scope(route_ref=self._route_ref(event_binding)): + with trace_mod.suppress_reporting(), trace_mod.scope(route_ref=self._route_ref(event_binding)): await self._dispatch_eba_message_to_pipeline( event, adapter, @@ -1167,21 +1277,31 @@ class RuntimeBot: envelope = self._eba_event_to_agent_envelope(event, adapter, execution_id) if target_type == 'event_processor': envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'}) + if isinstance(event, platform_events.MessageReceivedEvent): + # Use the SDK wire format for Source timestamps and nested chains. + # Pydantic's enclosing event serializer bypasses model_dump overrides. + envelope.data['message_chain'] = event.message_chain.model_dump() from ..telemetry import trace as trace_mod try: with trace_mod.scope(route_ref=self._route_ref(event_binding, target_type, target_uuid)): - async for _ in self.ap.agent_run_orchestrator.run( - envelope, - binding, - adapter_context={ - '_delivery_adapter': adapter, - '_platform_event': event, - '_execution_context': self.execution_context, - }, - ): - # Results are journaled by the orchestrator; platform sends require explicit actions. - pass + from .processing_indicator import processing_indicator + + reply_type, reply_id = (None, None) + if isinstance(event, platform_events.MessageReceivedEvent): + reply_type, reply_id, _ = self._infer_reply_target(event) + async with processing_indicator(adapter, reply_type, reply_id): + async for _ in self.ap.agent_run_orchestrator.run( + envelope, + binding, + adapter_context={ + '_delivery_adapter': adapter, + '_platform_event': event, + '_execution_context': self.execution_context, + }, + ): + # Results are journaled by the orchestrator; platform sends require explicit actions. + pass except Exception: return await self._record_event_route_trace( event_type=event_type, @@ -1236,10 +1356,7 @@ class RuntimeBot: """Record a discarded message in the monitoring system.""" try: if hasattr(message_chain, 'model_dump'): - chain_dump = message_chain.model_dump() - if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'): - chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump) - message_content = json.dumps(chain_dump, ensure_ascii=False) + message_content = message_chain.model_dump() else: message_content = str(message_chain) @@ -1407,13 +1524,18 @@ class RuntimeBot: conversation_id=conversation_id, actor_id=actor_id, ) + if record['processor_type'] not in {'agent', 'pipeline'}: + raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}') + from ..telemetry import trace as trace_mod + + # Only a validated callback resolving an original processor is eligible. + # Admit before acknowledgement and target lookup to preserve failures. + trace_mod.admit() await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter) if record['processor_type'] == 'agent': await self._resume_agent_interaction(record, event, adapter, actor_id, execution_id) return - if record['processor_type'] != 'pipeline': - raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}') pipeline = await self.ap.pipeline_service.get_pipeline( self.execution_context, @@ -1569,7 +1691,11 @@ class RuntimeBot: async for _ in self.ap.agent_run_orchestrator.run( envelope, binding, - adapter_context={'_delivery_adapter': adapter}, + adapter_context={ + '_delivery_adapter': adapter, + '_platform_event': event, + '_execution_context': self.execution_context, + }, ): # Resuming an interaction follows the same explicit-action delivery policy. pass @@ -1601,21 +1727,22 @@ class RuntimeBot: def tenant_scoped_listener(listener): @functools.wraps(listener) async def wrapped(*args, **kwargs): - tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) - cloud_runtime = ( - getattr( - getattr(self.ap.persistence_mgr, 'mode', None), - 'value', - None, + with language_scope(await get_workspace_language(self.ap, self.workspace_uuid)): + tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None) + cloud_runtime = ( + getattr( + getattr(self.ap.persistence_mgr, 'mode', None), + 'value', + None, + ) + == 'cloud_runtime' ) - == 'cloud_runtime' - ) - if cloud_runtime: - if not callable(tenant_scope): - raise RuntimeError('Cloud platform callbacks require a tenant scope') - async with tenant_scope(self.workspace_uuid): - return await listener(*args, **kwargs) - return await listener(*args, **kwargs) + if cloud_runtime: + if not callable(tenant_scope): + raise RuntimeError('Cloud platform callbacks require a tenant scope') + async with tenant_scope(self.workspace_uuid): + return await listener(*args, **kwargs) + return await listener(*args, **kwargs) return wrapped @@ -2162,6 +2289,10 @@ class PlatformManager: ) binding = _binding for bot in result.all(): + # Disabled drafts may not have the credentials needed to construct + # an adapter. They become runtime bots when explicitly enabled. + if not bot.enable: + continue try: if binding is None: binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid) @@ -2196,6 +2327,7 @@ class PlatformManager: bot_entity = persistence_bot.Bot(**bot_entity) execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid) + await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot_entity.adapter) if bot_entity.workspace_uuid != execution_context.workspace_uuid: raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context') if not _binding_validated: @@ -2241,6 +2373,9 @@ class PlatformManager: execution_context=execution_context, ) + if hasattr(adapter_inst, 'bind_runtime'): + adapter_inst.bind_runtime(runtime_bot) + await runtime_bot.initialize() bot_key = self._runtime_bot_key(runtime_bot) @@ -2282,6 +2417,7 @@ class PlatformManager: ) if bot is None or bot.placement_generation != execution_context.placement_generation: return None + await require_workspace_platform(self.ap, execution_context.workspace_uuid, bot.bot_entity.adapter) return bot async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None: diff --git a/src/langbot/pkg/platform/processing_indicator.py b/src/langbot/pkg/platform/processing_indicator.py new file mode 100644 index 000000000..723efa356 --- /dev/null +++ b/src/langbot/pkg/platform/processing_indicator.py @@ -0,0 +1,13 @@ +"""Optional platform processing status around actual processor execution.""" + +import contextlib + + +@contextlib.asynccontextmanager +async def processing_indicator(adapter, target_type, target_id): + factory = getattr(type(adapter), 'processing_indicator', None) + if callable(factory) and target_type and target_id is not None: + async with factory(adapter, target_type, str(target_id)): + yield + else: + yield diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.yaml b/src/langbot/pkg/platform/sources/aiocqhttp.yaml index d9880bcfe..fdabf4969 100644 --- a/src/langbot/pkg/platform/sources/aiocqhttp.yaml +++ b/src/langbot/pkg/platform/sources/aiocqhttp.yaml @@ -14,6 +14,7 @@ metadata: spec: legacy: true categories: + - popular - protocol help_links: zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat @@ -59,4 +60,4 @@ spec: execution: python: path: ./aiocqhttp.py - attr: AiocqhttpAdapter \ No newline at end of file + attr: AiocqhttpAdapter diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index 6d76ddd3d..a8fd2349d 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,3 +1,4 @@ +from langbot.libs.dingtalk_api.thinking import visible_think_markers import asyncio import json import pathlib @@ -13,7 +14,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf 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.entities.builtin.provider.session as provider_session -from langbot.libs.dingtalk_api.api import DingTalkClient +from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured import datetime from langbot.pkg.platform.logger import EventLogger from langbot.pkg.platform.human_input import format_human_input_text @@ -65,15 +66,19 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now()) ) - for atUser in event.incoming_message.at_users: - if atUser.dingtalk_id == event.incoming_message.chatbot_user_id: - yiri_msg_list.append(platform_message.At(target=bot_name)) + chatbot_id = getattr(event.incoming_message, 'chatbot_user_id', None) + mentioned = getattr(event.incoming_message, 'is_in_at_list', False) or any( + chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id + for at_user in getattr(event.incoming_message, 'at_users', []) or [] + ) + if mentioned: + yiri_msg_list.append(platform_message.At(target=bot_name)) if event.rich_content: elements = event.rich_content.get('Elements') for element in elements: if element.get('Type') == 'text': - text = element.get('Content', '').replace('@' + bot_name, '') + text = element.get('Content', '') if text.strip(): yiri_msg_list.append(platform_message.Plain(text=text)) elif element.get('Type') == 'image' and element.get('Picture'): @@ -82,7 +87,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte # 回退到原有简单逻辑 # 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理 if event.content and event.type != 'audio': - text_content = event.content.replace('@' + bot_name, '') + text_content = event.content yiri_msg_list.append(platform_message.Plain(text=text_content)) if event.picture: yiri_msg_list.append(platform_message.Image(base64=event.picture)) @@ -519,8 +524,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): required_keys = [ 'client_id', 'client_secret', - 'robot_name', - 'robot_code', ] missing_keys = [key for key in required_keys if key not in config] if missing_keys: @@ -528,12 +531,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot = DingTalkClient( client_id=config['client_id'], client_secret=config['client_secret'], - robot_name=config['robot_name'], - robot_code=config['robot_code'], + robot_name=config.get('robot_name', ''), + robot_code=config.get('robot_code', ''), markdown_card=config['markdown_card'], logger=logger, ) - bot_account_id = config['robot_name'] + bot_account_id = config['client_id'] super().__init__( config=config, logger=logger, @@ -610,8 +613,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): incoming_message = event.incoming_message markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain( + [part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))] + ) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) await self.bot.send_message(content, incoming_message, at) + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + await self.bot.send_attachment(part, incoming_message) async def reply_message_chunk( self, @@ -638,7 +647,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if (msg_seq - 1) % 8 == 0 or is_final: markdown_enabled = self.config.get('markdown_card', False) - content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) + text_message = platform_message.MessageChain( + [part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))] + ) + content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled) if not content and bot_message.content: content = bot_message.content # 兼容直接传入content的情况 @@ -707,6 +719,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): pass if bot_message.tool_calls is None: self.card_instance_id_dict.pop(message_id, None) + event = await DingTalkEventConverter.yiri2target(message_source) + for part in message: + if isinstance(part, (platform_message.Image, platform_message.File)): + await self.bot.send_attachment(part, event.incoming_message) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): markdown_enabled = self.config.get('markdown_card', False) @@ -717,15 +733,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.bot.send_proactive_message_to_group(target_id, content) async def is_stream_output_supported(self) -> bool: - is_stream = False - if self.config.get('enable-stream-reply', None): - is_stream = True - return is_stream + return is_stream_card_configured(self.config) async def create_message_card(self, message_id, event): self._prune_card_state() form_template_id = (self.config.get('human_input_card_template_id') or '').strip() - legacy_template_id = self.config.get('card_template_id', '') + legacy_template_id = (self.config.get('card_template_id') or '').strip() # Synthetic events (button clicks): look up the card already in # active_turn_card so reply_message_chunk can stream to it. @@ -776,7 +789,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Legacy chat-card path (no form template). incoming_message = event.source_platform_object.incoming_message - card_auto_layout = self.config.get('card_auto_layout', False) + card_auto_layout = self.config.get('card_auto_layout', True) card_instance, card_instance_id = await self.bot.create_and_card( legacy_template_id, incoming_message, card_auto_layout=card_auto_layout ) @@ -812,8 +825,9 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): async def on_message(event: DingTalkEvent): try: + self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id return await callback( - await self.event_converter.target2yiri(event, self.config['robot_name']), + await self.event_converter.target2yiri(event, self.bot_account_id), self, ) except Exception: @@ -853,6 +867,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if self.bot_avatar_media_id: params['bot_avatar'] = self.bot_avatar_media_id params.update(extra) + if isinstance(params.get('content'), str): + params['content'] = visible_think_markers(params['content']) return params async def kill(self) -> bool: @@ -1196,7 +1212,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): # Legacy chat-card template doesn't carry a `bot_avatar` # variable, so don't decorate the param map here. card_param_map = {'content': '', 'query': '...'} - card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)} + card_data_config = {'autoLayout': self.config.get('card_auto_layout', True)} try: success = await self.bot.create_and_deliver_card( card_template_id=template_id, diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index f8a7e6395..43392cf0b 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -14,6 +14,7 @@ metadata: spec: legacy: true categories: + - popular - china help_links: zh: https://langbot.app/docs/zh/usage/platforms/dingtalk @@ -26,9 +27,9 @@ spec: zh_Hans: 一键创建应用 zh_Hant: 一鍵建立應用 description: - en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually." - zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。" - zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。" + en_US: Scan with DingTalk to create an app and automatically fill in credentials. The bot identity is resolved automatically. + zh_Hans: 使用钉钉扫码创建应用并自动填写凭据,机器人身份会自动识别。 + zh_Hant: 使用釘釘掃碼建立應用並自動填寫憑證,機器人身分會自動識別。 type: qr-code-login login_platform: dingtalk required: false @@ -48,78 +49,96 @@ spec: type: string required: true default: "" - - name: robot_code - label: - en_US: Robot Code - zh_Hans: 机器人代码 - zh_Hant: 機器人代碼 - description: - en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration." - zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。" - zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。" - type: string - required: true - default: "" - - name: robot_name - label: - en_US: Robot Name - zh_Hans: 机器人名称 - zh_Hant: 機器人名稱 - type: string - required: true - default: "" - name: markdown_card label: - en_US: Markdown Card - zh_Hans: 是否使用 Markdown 卡片 - zh_Hant: 是否使用 Markdown 卡片 + en_US: Use Card Replies + zh_Hans: 使用卡片回复 + zh_Hant: 使用卡片回覆 + description: + en_US: Controls Markdown and streaming card replies. Disable to use plain text while keeping your template settings. When enabled, a configured template automatically enables streaming. + zh_Hans: 控制 Markdown 和流式卡片回复。关闭后使用纯文本,保留模板配置;开启且已配置模板时自动使用流式回复。 + zh_Hant: 控制 Markdown 和串流卡片回覆。關閉後使用純文字,保留範本設定;開啟且已設定範本時自動使用串流回覆。 type: boolean required: false default: true - - name: enable-stream-reply - label: - en_US: Enable Stream Reply Mode - zh_Hans: 启用钉钉卡片流式回复模式 - zh_Hant: 啟用釘釘卡片串流回覆模式 - description: - en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode - zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容 - zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容 - type: boolean - required: true - default: false - - name: card_auto_layout - label: - en_US: Card Auto Layout - zh_Hans: 卡片宽屏自动布局 - zh_Hant: 卡片寬螢幕自動佈局 - type: boolean - required: false - default: false - name: card_template_id + show_if: + field: markdown_card + operator: eq + value: true label: en_US: card template id zh_Hans: 卡片模板ID zh_Hant: 卡片範本ID + description: + en_US: Template for streaming text replies. Save and publish it in DingTalk Card Platform, then paste its ID here to automatically enable streaming while card replies are enabled. Leave empty for ordinary replies. Separate from the human input template. + zh_Hans: 用于流式文字回复。在钉钉卡片平台保存并发布模板后,开启卡片回复后,填写 ID 即自动启用流式回复,留空使用普通回复;与人工输入卡片模板分开配置。 + zh_Hant: 用於串流文字回覆。在釘釘卡片平台儲存並發布範本後,開啟卡片回覆後,填寫 ID 即自動啟用串流回覆,留空使用一般回覆;與人工輸入卡片範本分開設定。 type: string - required: true - default: "填写你的卡片template_id" + required: false + default: "" + - name: stream_card_template_download + show_if: + field: markdown_card + operator: eq + value: true + label: + en_US: Download Streaming Reply Card Template + zh_Hans: 下载流式回复卡片模板 + zh_Hant: 下載串流回覆卡片範本 + description: + en_US: Displays reasoning and the answer separately. Import through More > Import Card Template in the DingTalk AI card editor, save and publish, then paste its ID into Card Template ID above. Respects the pipeline thinking removal setting. + zh_Hans: 分开展示思考与正文。在钉钉 AI 卡片编辑器中选择「更多 → 导入卡片模板」,保存并发布后,将 ID 填入上方「卡片模板 ID」。是否保留思考仍由流水线设置控制。 + zh_Hant: 分開展示思考與正文。在釘釘 AI 卡片編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布後,將 ID 填入上方「卡片範本 ID」。是否保留思考仍由流水線設定控制。 + type: download-link + required: false + default: '' + url: /api/v1/platform/adapters/dingtalk/stream-card-template + download_filename: dingtalk_stream_card.json + - name: card_auto_layout + show_if: + field: markdown_card + operator: eq + value: true + label: + en_US: Card Auto Layout + zh_Hans: 卡片宽屏自动布局 + zh_Hant: 卡片寬螢幕自動佈局 + description: + en_US: Enabled by default. Allows supported cards to adapt to wider windows. The LangBot streaming reply template includes the required variables; you can disable this for a narrower layout. + zh_Hans: 默认开启,让支持的卡片适应较宽的聊天窗口。LangBot 流式回复模板已包含所需变量;如需较窄布局,可关闭此项。 + zh_Hant: 預設開啟,讓支援的卡片適應較寬的聊天視窗。LangBot 串流回覆範本已包含所需變數;如需較窄版面,可關閉此項。 + type: boolean + required: false + default: true + - name: human_input_card_template_id + label: + en_US: Human Input Card Template ID + zh_Hans: 人工输入卡片模板ID + zh_Hant: 人工輸入卡片範本ID + description: + en_US: Optional. Used when a workflow or plugin requests user input, a choice, or confirmation before continuing (for example, a Dify Human Input node). Ordinary chat does not require it. Import the template below and enter its published ID here; streaming text replies use the separate Card Template ID. + zh_Hans: 可选。当工作流或插件需要用户填写信息、选择选项或确认后再继续执行时使用,例如 Dify 的人工输入节点。普通聊天无需配置。请导入下方模板,将发布后的 ID 填在这里;流式文字回复使用单独的「卡片模板 ID」。 + zh_Hant: 可選。當工作流或外掛需要使用者填寫資訊、選擇選項或確認後再繼續執行時使用,例如 Dify 的人工輸入節點。一般聊天無需設定。請匯入下方範本,將發布後的 ID 填在這裡;串流文字回覆使用獨立的「卡片範本 ID」。 + type: string + required: false + default: "" - name: human_input_card_template_download label: en_US: Download Human Input Card Template zh_Hans: 下载人工输入卡片模板 zh_Hant: 下載人工輸入卡片範本 description: - en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior." - zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。" - zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。" + en_US: Download the human input template. Create an AI card in DingTalk Card Platform, choose More > Import Card Template in the editor, then save and publish. Paste its ID into the field above. + zh_Hans: 下载人工输入模板后,在钉钉卡片平台新建 AI 卡片,在编辑器中选择「更多 → 导入卡片模板」,保存并发布,将 ID 填入上方字段。 + zh_Hant: 下載人工輸入範本後,在釘釘卡片平台新建 AI 卡片,在編輯器中選擇「更多 → 匯入卡片範本」,儲存並發布,將 ID 填入上方欄位。 type: download-link required: false default: "" url: /api/v1/platform/adapters/dingtalk/human-input-card-template download_filename: dingtalk_human_input_card.json help_links: - zh: https://open-dev.dingtalk.com/fe/card + zh: https://langbot.app/docs/zh/usage/platforms/dingtalk#导入人工输入卡片模板 en: https://open-dev.dingtalk.com/fe/card ja: https://open-dev.dingtalk.com/fe/card help_label: @@ -127,18 +146,6 @@ spec: zh_Hans: 导入指引 zh_Hant: 匯入指引 ja_JP: インポート手順 - - name: human_input_card_template_id - label: - en_US: Human Input Card Template ID - zh_Hans: 人工输入卡片模板ID - zh_Hant: 人工輸入卡片範本ID - description: - en_US: "Paste the template ID generated after importing the human input card template." - zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。" - zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。" - type: string - required: false - default: "" execution: python: path: ./dingtalk.py diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index e7119e391..1d9a29234 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -1,5 +1,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + import discord from discord import ui as discord_ui @@ -219,11 +222,13 @@ class VoiceConnectionManager: # 获取服务器和频道对象 guild = self.bot.get_guild(guild_id) if not guild: - raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id) + raise VoiceConnectionError(t('Resource not found') + f': {guild_id}', 'GUILD_NOT_FOUND', guild_id) channel = guild.get_channel(channel_id) if not channel or not isinstance(channel, discord.VoiceChannel): - raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id) + raise VoiceConnectionError( + t('Resource not found') + f': {channel_id}', 'CHANNEL_NOT_FOUND', guild_id + ) # 验证用户是否在语音频道中(如果提供了用户ID) if user_id: @@ -258,12 +263,14 @@ class VoiceConnectionManager: return voice_client except discord.ClientException as e: - raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}') + raise VoiceNetworkError(t('Voice connection failed') + f': {e}') except discord.opus.OpusNotLoaded as e: - raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id) + raise VoiceConnectionError( + t('Voice service is unavailable') + f': Opus: {e}', 'OPUS_NOT_LOADED', guild_id + ) except Exception as e: await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}') - raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id) + raise VoiceConnectionError(t('Voice connection failed') + f': {e}', 'UNKNOWN_ERROR', guild_id) async def leave_voice_channel(self, guild_id: int) -> bool: """ @@ -494,11 +501,13 @@ class VoiceConnectionManager: """ member = guild.get_member(user_id) if not member: - raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id) + raise VoicePermissionError( + t('Resource not found') + f': {user_id}', ['member_not_found'], user_id, channel.id + ) if not member.voice or member.voice.channel != channel: raise VoicePermissionError( - f'用户 {member.display_name} 不在语音频道 {channel.name} 中', + t('User is not in the voice channel') + f': {member.display_name}, {channel.name}', ['user_not_in_channel'], user_id, channel.id, @@ -528,7 +537,7 @@ class VoiceConnectionManager: if missing_permissions: raise VoicePermissionError( - f'机器人在频道 {channel.name} 中缺少权限: {", ".join(missing_permissions)}', + t('Permission denied') + f': {channel.name}: {", ".join(missing_permissions)}', missing_permissions, channel_id=channel.id, ) @@ -843,7 +852,7 @@ class DiscordFormView(discord_ui.View): # silently — most Dify forms have ≤10 actions in practice. for idx, action in enumerate(actions[:25]): action_id = str(action.get('id') or '') - label = str(action.get('title') or action_id or f'Option {idx + 1}') + label = str(action.get('title') or action_id or adapter_t(adapter, 'Option {number}', number=idx + 1)) style = self._STYLE_MAP.get( str(action.get('button_style') or '').lower(), discord.ButtonStyle.secondary, @@ -966,6 +975,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self._pending_forms.pop(next(iter(self._pending_forms)), None) # Voice functionality methods + @localized_adapter async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: """ 加入语音频道 @@ -990,7 +1000,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): VoiceConnectionError: 其他连接错误 """ if not self.voice_manager: - raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY') + raise VoiceConnectionError(t('Voice service is unavailable'), 'MANAGER_NOT_READY') return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id) @@ -1358,7 +1368,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) return - node_title = str(form_data.get('node_title') or 'Confirmation needed') + node_title = str(form_data.get('node_title') or adapter_t(self, 'Confirmation needed')) form_content = str(form_data.get('form_content') or '').strip() # Two paths: diff --git a/src/langbot/pkg/platform/sources/http_bot.yaml b/src/langbot/pkg/platform/sources/http_bot.yaml index 97392a444..5e13f437e 100644 --- a/src/langbot/pkg/platform/sources/http_bot.yaml +++ b/src/langbot/pkg/platform/sources/http_bot.yaml @@ -8,10 +8,14 @@ metadata: 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)に対応。 + en_US: Connect backend systems via HTTP. Send messages through signed webhooks and receive replies via callbacks. + zh_Hans: 通过 HTTP 接入后端系统,以签名 Webhook 发送消息,通过回调接收回复。 + zh_Hant: 透過 HTTP 接入後端系統,以簽名 Webhook 傳送訊息,透過回呼接收回覆。 + ja_JP: HTTP でバックエンドに接続。署名付き Webhook でメッセージを送信し、コールバックで返信を受信します。 + es_ES: Conecta sistemas mediante HTTP. Envía mensajes con webhooks firmados y recibe respuestas mediante callbacks. + ru_RU: Подключайте серверные системы по HTTP. Отправляйте сообщения через подписанные вебхуки и получайте ответы через обратные вызовы. + th_TH: เชื่อมต่อระบบผ่าน HTTP ส่งข้อความด้วย Webhook ที่ลงลายเซ็นและรับคำตอบผ่าน callback + vi_VN: Kết nối hệ thống qua HTTP. Gửi tin nhắn bằng webhook có chữ ký và nhận phản hồi qua callback. icon: http_bot.svg spec: categories: diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index a1672045e..c4bd5c8a9 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -1,5 +1,7 @@ from __future__ import annotations +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter + import lark_oapi from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateFileRequest, CreateFileRequestBody import traceback @@ -1241,7 +1243,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} + ) if action_value == '有帮助': feedback_type = 1 @@ -1250,7 +1254,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} + ) operator = getattr(event.event, 'operator', None) context = getattr(event.event, 'context', None) @@ -1287,13 +1293,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} + ) except Exception: traceback.print_exc() schedule_on_app_loop(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse - return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}}) + return P2CardActionTriggerResponse( + {'toast': {'type': 'error', 'content': adapter_t(self, 'Failed to process feedback')}} + ) event_handler = ( lark_oapi.EventDispatcherHandler.builder('', '') @@ -1546,6 +1556,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return True return False + @localized_adapter async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) @@ -1810,7 +1821,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'elements': [ { 'tag': 'markdown', - 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'content': '' + + adapter_t( + self, 'AI-generated content. Refer to the cited sources for details.' + ) + + '', 'text_align': 'left', 'text_size': 'notation', 'margin': '4px 0px 0px 0px', @@ -1841,7 +1856,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'fill', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'hover_tips': {'tag': 'plain_text', 'content': adapter_t(self, 'Helpful')}, 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -1865,7 +1880,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'default', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Not helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -2065,6 +2083,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) return new_card_id + @localized_adapter async def reply_message( self, message_source: platform_events.MessageEvent, @@ -2175,6 +2194,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' ) + @localized_adapter async def reply_message_chunk( self, message_source: platform_events.MessageEvent, @@ -2492,7 +2512,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'tag': 'select_static', 'name': component_name, 'label': {'tag': 'plain_text', 'content': field_name}, - 'placeholder': {'tag': 'plain_text', 'content': '请选择'}, + 'placeholder': {'tag': 'plain_text', 'content': adapter_t(self, 'Select an option')}, 'options': [ { 'text': {'tag': 'plain_text', 'content': option}, @@ -2526,7 +2546,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'tag': 'input', 'name': component_name, 'label': {'tag': 'plain_text', 'content': field_name}, - 'placeholder': {'tag': 'plain_text', 'content': '请输入'}, + 'placeholder': {'tag': 'plain_text', 'content': adapter_t(self, 'Enter a value')}, 'default_value': _dify_default_value(field), 'width': 'fill', 'required': False, @@ -2821,7 +2841,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'elements': [ { 'tag': 'markdown', - 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'content': '' + + adapter_t( + self, 'AI-generated content. Refer to the cited sources for details.' + ) + + '', 'text_align': 'left', 'text_size': 'notation', 'margin': '4px 0px 0px 0px', @@ -2856,7 +2880,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'fill', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -2880,7 +2907,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'width': 'default', 'size': 'medium', 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, - 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'hover_tips': { + 'tag': 'plain_text', + 'content': adapter_t(self, 'Not helpful'), + }, 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], 'margin': '0px 0px 0px 0px', } @@ -3007,6 +3037,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): token = data.get('token') return isinstance(token, str) and hmac.compare_digest(expected.encode('utf-8'), token.encode('utf-8')) + @localized_adapter async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: @@ -3178,14 +3209,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): }, ) - return {'toast': {'type': 'success', 'content': '操作成功'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} if action_value == '有帮助': feedback_type = 1 elif action_value == '无帮助': feedback_type = 2 else: - return {'toast': {'type': 'success', 'content': '操作成功'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Done')}} user_id = operator.get('open_id') or operator.get('user_id') open_chat_id = context_data.get('open_chat_id') @@ -3217,10 +3248,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if platform_events.FeedbackEvent in self.listeners: await self.listeners[platform_events.FeedbackEvent](feedback_event, self) - return {'toast': {'type': 'success', 'content': '感谢您的反馈'}} + return {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} except Exception: await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}') - return {'toast': {'type': 'error', 'content': '反馈处理失败'}} + return {'toast': {'type': 'error', 'content': adapter_t(self, 'Failed to process feedback')}} elif 'im.chat.member.bot.added_v1' == type: try: @@ -3273,6 +3304,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.logger.error(f'Error in lark callback: {traceback.format_exc()}') return {'code': 500, 'message': 'error'} + @localized_adapter async def run_async(self): enable_webhook = self.config['enable-webhook'] diff --git a/src/langbot/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml index 83f520286..b99ab5b2c 100644 --- a/src/langbot/pkg/platform/sources/line.yaml +++ b/src/langbot/pkg/platform/sources/line.yaml @@ -20,6 +20,7 @@ metadata: icon: line.png spec: categories: + - popular - global help_links: zh: https://langbot.app/docs/zh/usage/platforms/line @@ -81,4 +82,4 @@ spec: execution: python: path: ./line.py - attr: LINEAdapter \ No newline at end of file + attr: LINEAdapter diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py index f0a9822e3..3fc311392 100644 --- a/src/langbot/pkg/platform/sources/matrix.py +++ b/src/langbot/pkg/platform/sources/matrix.py @@ -1,5 +1,8 @@ from __future__ import annotations +from langbot.pkg.i18n import t +from langbot.pkg.i18n.adapters import localized_adapter + import typing import asyncio import traceback @@ -666,25 +669,26 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): except Exception: await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}') + @localized_adapter async def _handle_relogin_command(self, room_id: str): """Handle !relogin command: logout then re-login all bridges.""" if not self._bridges: await self.client.room_send( room_id=room_id, message_type='m.room.message', - content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + content={'msgtype': 'm.text', 'body': t('No bridges configured.')}, ) return - lines = ['开始重新登录所有桥...'] + lines = [t('Signing in to all bridges again...')] for bridge in self._bridges: if not bridge.login_command or not bridge.dm_room_id: - lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)') + lines.append(t('Bridge {bridge}: skipped (login command or DM room missing).', bridge=bridge.user_id)) continue # Use configured logout command, fallback to deriving from login command logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') - lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...') + lines.append(t('Bridge {bridge}: sending {command}...', bridge=bridge.user_id, command=logout_cmd)) # Cancel existing tasks if bridge.login_task and not bridge.login_task.done(): @@ -700,14 +704,14 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content={'msgtype': 'm.text', 'body': logout_cmd}, ) except Exception as e: - lines.append(f'[{bridge.user_id}] logout 发送失败: {e}') + lines.append(t('Bridge {bridge}: sign-out failed.', bridge=bridge.user_id) + f' {e}') await asyncio.sleep(2) # Reset state and restart login bridge.logged_in = False self._restart_bridge_login(bridge) - lines.append(f'[{bridge.user_id}] 已触发重新登录') + lines.append(t('Bridge {bridge}: sign-in started.', bridge=bridge.user_id)) await self.client.room_send( room_id=room_id, @@ -715,20 +719,21 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content={'msgtype': 'm.text', 'body': '\n'.join(lines)}, ) + @localized_adapter async def _handle_status_command(self, room_id: str): """Handle !status command: show bridge states.""" if not self._bridges: await self.client.room_send( room_id=room_id, message_type='m.room.message', - content={'msgtype': 'm.text', 'body': '没有配置任何桥。'}, + content={'msgtype': 'm.text', 'body': t('No bridges configured.')}, ) return - lines = ['桥状态:'] + lines = [t('Bridge status:')] for bridge in self._bridges: - status = '已登录 ✓' if bridge.logged_in else '未登录 ✗' - dm = bridge.dm_room_id or '无' + status = t('Signed in ✓') if bridge.logged_in else t('Not signed in ✗') + dm = bridge.dm_room_id or t('None') lines.append(f'• {bridge.user_id}: {status} (DM: {dm})') await self.client.room_send( room_id=room_id, diff --git a/src/langbot/pkg/platform/sources/mattermost.yaml b/src/langbot/pkg/platform/sources/mattermost.yaml index f46b125bb..6c958e42d 100644 --- a/src/langbot/pkg/platform/sources/mattermost.yaml +++ b/src/langbot/pkg/platform/sources/mattermost.yaml @@ -22,7 +22,6 @@ metadata: spec: categories: - global - - popular config: - name: server_url label: diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.py b/src/langbot/pkg/platform/sources/openclaw_weixin.py index 4726888b1..b4c9dc270 100644 --- a/src/langbot/pkg/platform/sources/openclaw_weixin.py +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import base64 +import contextlib import os import traceback import typing @@ -256,6 +257,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda # context_token cache: from_user_id -> context_token _context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict) + _typing_indicator: typing.Any = pydantic.PrivateAttr(default=None) + _polling: bool = pydantic.PrivateAttr(default=False) _poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None) _bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None) @@ -347,6 +350,18 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda # Persist token to database so it survives restart await self._persist_config() + @contextlib.asynccontextmanager + async def processing_indicator(self, target_type, target_id): + if target_type not in ('person', 'friend'): + yield + return + from langbot.libs.openclaw_weixin_api.typing_indicator import TypingIndicator + + if self._typing_indicator is None: + self._typing_indicator = TypingIndicator(self.client, lambda peer: self._context_tokens.get(peer, '')) + async with self._typing_indicator.processing(target_id): + yield + async def send_message( self, target_type: str, @@ -385,6 +400,7 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda await self.logger.error( f'Failed to send component {type(component).__name__}: {traceback.format_exc()}' ) + raise async def reply_message( self, @@ -478,6 +494,9 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda token=self.config.get('token', token), ) self.bot_account_id = self.config.get('account_id', 'openclaw-weixin') + from langbot.libs.openclaw_weixin_api.typing_indicator import TypingIndicator + + self._typing_indicator = TypingIndicator(self.client, lambda peer: self._context_tokens.get(peer, '')) self._polling = True # Start the long-poll loop @@ -488,6 +507,9 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda await self._poll_task except asyncio.CancelledError: pass + finally: + if self._typing_indicator is not None: + await self._typing_indicator.close() async def _poll_loop(self): """Long-poll loop: call getUpdates continuously. @@ -522,6 +544,9 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda await self.logger.error('OpenClaw WeChat session expired, attempting re-login...') try: await self._do_login() + if self._typing_indicator is not None: + await self._typing_indicator.close() + self._typing_indicator = None # Rebuild client with new credentials self.client = OpenClawWeixinClient( base_url=self.config.get('base_url', DEFAULT_BASE_URL), @@ -615,6 +640,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda async def kill(self) -> bool: """Stop the adapter.""" self._polling = False + if self._typing_indicator is not None: + await self._typing_indicator.close() if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() try: diff --git a/src/langbot/pkg/platform/sources/slack.py b/src/langbot/pkg/platform/sources/slack.py index df18a0765..86481f9a1 100644 --- a/src/langbot/pkg/platform/sources/slack.py +++ b/src/langbot/pkg/platform/sources/slack.py @@ -6,6 +6,8 @@ import traceback import datetime from langbot.libs.slack_api.api import SlackClient +from langbot.libs.slack_api.media import send_chain +from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter as OmniMessageConverter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot.libs.slack_api.slackevent import SlackEvent import langbot_plugin.api.entities.builtin.platform.events as platform_events @@ -61,13 +63,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def target2yiri(event: SlackEvent, bot: SlackClient): - yiri_chain = await SlackMessageConverter.target2yiri( - message=event.text, message_id=event.message_id, pic_url=event.pic_url, bot=bot - ) + yiri_chain = await OmniMessageConverter.target2yiri(event, bot.bot_token) if event.type == 'channel': - yiri_chain.insert(0, platform_message.At(target='SlackBot')) - sender = platform_entities.GroupMember( id=event.user_id, member_name=str(event.sender_name), @@ -102,14 +100,17 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): def __init__(self, config: dict, logger: EventLogger): required_keys = [ 'bot_token', - 'signing_secret', + 'app_token' if config.get('socket_mode', False) else 'signing_secret', ] - missing_keys = [key for key in required_keys if key not in config] + missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员') bot = SlackClient( - bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True + bot_token=config['bot_token'], + signing_secret=config.get('signing_secret', ''), + logger=logger, + unified_mode=True, ) super().__init__( @@ -127,27 +128,13 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): slack_event = await SlackEventConverter.yiri2target(message_source) - content_list = await SlackMessageConverter.yiri2target(message) - - for content in content_list: - # Both text and image (URL) are sent as text messages - # Slack will auto-unfurl image URLs - message_content = content['content'] - if slack_event.type == 'channel': - await self.bot.send_message_to_channel(message_content, slack_event.channel_id) - if slack_event.type == 'im': - await self.bot.send_message_to_one(message_content, slack_event.user_id) + target_type = 'channel' if slack_event.type == 'channel' else 'person' + target_id = slack_event.channel_id if target_type == 'channel' else slack_event.user_id + return await send_chain(self.bot, target_type, target_id, message) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - content_list = await SlackMessageConverter.yiri2target(message) - for content in content_list: - # Both text and image (URL) are sent as text messages - # Slack will auto-unfurl image URLs - message_content = content['content'] - if target_type == 'person': - await self.bot.send_message_to_one(message_content, target_id) - if target_type == 'group': - await self.bot.send_message_to_channel(message_content, target_id) + target_type = 'channel' if target_type in {'group', 'channel'} else target_type + return await send_chain(self.bot, target_type, target_id, message) def register_listener( self, @@ -186,6 +173,10 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return await self.bot.handle_unified_webhook(request) async def run_async(self): + if self.config.get('socket_mode', False): + await self.bot.run_socket(self.config['app_token']) + return + # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 async def keep_alive(): @@ -195,7 +186,8 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await keep_alive() async def kill(self) -> bool: - return False + await self.bot.close_socket() + return True async def unregister_listener( self, diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index 44fd4b337..f6bbd97e2 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -12,9 +12,9 @@ metadata: es_ES: Slack description: en_US: Slack Adapter - zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式 - zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式 - ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。 + zh_Hans: Slack 适配器,支持 Webhook 和无需公网地址的长连接,请查看文档了解使用方式 + zh_Hant: Slack 適配器,支援 Webhook 或無需公開網址的 Socket Mode。 + ja_JP: Slack アダプター。Webhook または公開 URL が不要な Socket Mode を利用できます。 th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso @@ -29,7 +29,26 @@ spec: en: https://langbot.app/docs/en/usage/platforms/slack ja: https://langbot.app/docs/ja/usage/platforms/slack config: + - name: quick_setup + label: {en_US: Quick Slack setup, zh_Hans: Slack 快速配置, zh_Hant: Slack 快速設定, ja_JP: Slack クイック設定} + description: {en_US: Create and configure an app using App Configuration Tokens., zh_Hans: 填入配置令牌,自动创建应用并配置权限,授权后填入凭据。, zh_Hant: 填入設定權杖,自動建立應用程式並設定權限,授權後填入憑證。, ja_JP: 設定トークンでアプリを作成し、承認後に認証情報を入力します。} + type: qr-code-login + login_platform: slack + required: false + - name: socket_mode + label: {en_US: Socket Mode, zh_Hans: 长连接模式, zh_Hant: 長連線模式, ja_JP: Socket Mode} + type: boolean + default: false + required: false + - name: app_token + label: {en_US: App-Level Token, zh_Hans: App-Level Token, zh_Hant: App-Level Token, ja_JP: App-Level Token} + description: {en_US: Generate an xapp- token with connections:write in Basic Information., zh_Hans: 在 Basic Information 中生成带 connections:write 权限的 xapp- 令牌。, zh_Hant: 在 Basic Information 產生具有 connections:write 權限的 xapp- 權杖。, ja_JP: Basic Information で connections:write 権限の xapp- トークンを生成してください。} + type: string + required: false + default: '' + show_if: {field: socket_mode, operator: eq, value: true} - name: webhook_url + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Webhook Callback URL zh_Hans: Webhook 回调地址 @@ -62,6 +81,7 @@ spec: required: true default: "" - name: signing_secret + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: signing_secret zh_Hans: 密钥 @@ -71,7 +91,7 @@ spec: vi_VN: Khóa ký es_ES: Secreto de firma type: string - required: true + required: false default: "" execution: python: diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 905fc459c..d0a531355 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -334,6 +334,24 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ): if field_name in PluginWorkerPolicy.model_fields and field_name in worker: policy_data[field_name] = worker.get(field_name) + if self.runtime_profile == 'shared': + for mode in ('dedicated', 'shared'): + overrides = worker.get(mode) + if isinstance(overrides, dict): + overrides = dict(overrides) + for name, value in overrides.items(): + # Optional leaves start as null, so env loading preserves + # strings. Parse only these trusted numeric config fields. + if isinstance(value, str) and name in ( + 'max_cpus', + 'max_memory_mb', + 'max_pids', + 'max_open_files', + 'max_file_size_mb', + ): + overrides[name] = float(value) if name == 'max_cpus' else int(value) + if overrides is not None: + policy_data[mode] = overrides return PluginWorkerPolicy.model_validate(policy_data) def _control_headers(self, *, allow_generate: bool) -> dict[str, str]: diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index c8d12c340..413ccd678 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1,5 +1,7 @@ from __future__ import annotations +from contextlib import aclosing + import asyncio import inspect import typing @@ -441,13 +443,19 @@ def _resolve_remove_think(data: dict[str, Any], query: Any | None) -> bool: return False -def _merge_model_extra_args(model: Any, call_extra_args: Any) -> dict[str, Any]: +def _merge_model_extra_args(model: Any, call_extra_args: Any, *, llm_policy: bool = False) -> dict[str, Any]: """Merge persisted model extra_args with action-level overrides.""" merged: dict[str, Any] = {} model_extra_args = getattr(getattr(model, 'model_entity', None), 'extra_args', None) if isinstance(model_extra_args, dict): merged.update(model_extra_args) + if llm_policy: + # RuntimeProvider resolves model and call policy layers independently. + # Flattening them here makes a model request_policy.retries override a + # caller's legacy num_retries=0 (and similarly hides timeout overrides). + for key in ('request_policy', 'num_retries', 'max_retries', 'timeout'): + merged.pop(key, None) if isinstance(call_extra_args, dict): merged.update(call_extra_args) @@ -1110,6 +1118,14 @@ class RuntimeConnectionHandler(handler.Handler): message=f'Query with query_id {query_id} not found', ) + from ..agent.runner.context_reset import reset_runner_context + from ..agent.runner.query_entry_adapter import QueryEntryAdapter + + conversation_id = QueryEntryAdapter._build_conversation_context(query).conversation_id + async with self.ap.persistence_mgr.tenant_uow(action_context.workspace_uuid): + await reset_runner_context( + self.ap.persistence_mgr, action_context.workspace_uuid, query.bot_uuid, conversation_id + ) query.session.using_conversation = None return handler.ActionResponse.success( @@ -1482,7 +1498,9 @@ class RuntimeConnectionHandler(handler.Handler): funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] query = _resolve_action_query(data, session, self.ap, action_context) - effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + if query is None: + query = _build_plugin_action_query(action_context) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args, llm_policy=True) remove_think = _resolve_remove_think(data, query) effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] @@ -1578,26 +1596,31 @@ class RuntimeConnectionHandler(handler.Handler): funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs] query = _resolve_action_query(data, session, self.ap, action_context) - effective_extra_args = _merge_model_extra_args(llm_model, extra_args) + if query is None: + query = _build_plugin_action_query(action_context) + effective_extra_args = _merge_model_extra_args(llm_model, extra_args, llm_policy=True) remove_think = _resolve_remove_think(data, query) effective_funcs = funcs_obj if 'func_call' in (llm_model.model_entity.abilities or []) else [] - async for chunk in llm_model.provider.invoke_llm_stream( - query=query, - model=llm_model, - messages=messages_obj, - funcs=effective_funcs, - extra_args=effective_extra_args, - remove_think=remove_think, - execution_context=execution_context, - ): - if chunk is None: - continue - yield handler.ActionResponse.success( - data={ - 'chunk': chunk.model_dump(), - }, + async with aclosing( + llm_model.provider.invoke_llm_stream( + query=query, + model=llm_model, + messages=messages_obj, + funcs=effective_funcs, + extra_args=effective_extra_args, + remove_think=remove_think, + execution_context=execution_context, ) + ) as owned_stream: + async for chunk in owned_stream: + if chunk is None: + continue + yield handler.ActionResponse.success( + data={ + 'chunk': chunk.model_dump(), + }, + ) usage = _pop_query_llm_usage(query) if usage is not None: yield handler.ActionResponse.success( @@ -2800,7 +2823,7 @@ class RuntimeConnectionHandler(handler.Handler): return await self.call_action( LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION, request.model_dump(exclude_none=True), - timeout=120, + timeout=600, # dependency install can be slow ) async def remove_plugin_installation( @@ -2824,7 +2847,7 @@ class RuntimeConnectionHandler(handler.Handler): 'install_source': install_source, 'install_info': install_info, }, - timeout=120, + timeout=600, # dependency install can be slow ) async for ret in gen: @@ -2838,7 +2861,7 @@ class RuntimeConnectionHandler(handler.Handler): 'plugin_author': plugin_author, 'plugin_name': plugin_name, }, - timeout=120, + timeout=600, # dependency install can be slow ) async for ret in gen: diff --git a/src/langbot/pkg/plugin/runner_actions.py b/src/langbot/pkg/plugin/runner_actions.py index bca1a6c07..659ac2f4c 100644 --- a/src/langbot/pkg/plugin/runner_actions.py +++ b/src/langbot/pkg/plugin/runner_actions.py @@ -469,12 +469,16 @@ def register(h): if payload_error: return handler.ActionResponse.error(message=payload_error) - event = await store.append_event( + from ..agent.runner.run_journal import AgentRunJournal + + event = await AgentRunJournal(h.ap).append_run_result( run_id=str(target_run_id), sequence=sequence, - event_type=str(event_type), - data=event_payload, - usage=usage if isinstance(usage, dict) else None, + result_dict={ + 'type': str(event_type), + 'data': event_payload, + 'usage': usage if isinstance(usage, dict) else None, + }, source=str(data.get('source') or result.get('source') or 'runner'), metadata=metadata, ) diff --git a/src/langbot/pkg/provider/modelmgr/codex_auth.py b/src/langbot/pkg/provider/modelmgr/codex_auth.py index 571a67b40..1f6b618ec 100644 --- a/src/langbot/pkg/provider/modelmgr/codex_auth.py +++ b/src/langbot/pkg/provider/modelmgr/codex_auth.py @@ -23,6 +23,7 @@ from ...api.http.context import PrincipalType, RequestContext from ...api.http.authz import Permission, has_permission from ...api.http.service.tenant import require_workspace_uuid from ...workspace.errors import WorkspaceNotFoundError +from ...i18n import t REQUESTER = 'openai-codex' BASE_URL = 'https://chatgpt.com/backend-api/codex' @@ -77,7 +78,7 @@ def _tokens(data: dict, previous: dict | None = None) -> dict: or not math.isfinite(expires_at) or expires_at <= time.time() ): - raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.') + raise ValueError(t('ChatGPT returned an incomplete authorization. Please sign in again.')) return { 'access_token': access, 'refresh_token': refresh, @@ -99,7 +100,7 @@ class CodexAuth: try: return await self.ap.persistence_mgr.execute_async(statement) except Exception: - raise ValueError('ChatGPT credential storage failed. Please retry.') from None + raise ValueError(t('ChatGPT credential storage failed. Please retry.')) from None async def _read(self, workspace: str, provider: str) -> dict | None: result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider))) @@ -107,7 +108,7 @@ class CodexAuth: row = result.first() return dict(row._mapping) if row is not None else None except Exception: - raise ValueError('ChatGPT credential storage failed. Please retry.') from None + raise ValueError(t('ChatGPT credential storage failed. Please retry.')) from None async def _provider(self, context, provider: str, *, user: bool = False) -> str: workspace = require_workspace_uuid(context) @@ -117,7 +118,7 @@ class CodexAuth: or not context.account_uuid or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE) ): - raise ValueError('ChatGPT authorization requires an authorized workspace user') + raise ValueError(t('ChatGPT authorization requires an authorized workspace user')) result = await self._execute( sa.select(ModelProvider.requester).where( ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider @@ -147,9 +148,9 @@ class CodexAuth: if result.rowcount == 1: break if await self._read(workspace, provider) is None: - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if time.monotonic() >= deadline: - raise ValueError('ChatGPT authorization is busy. Please retry shortly.') + raise ValueError(t('ChatGPT authorization is busy. Please retry shortly.')) await asyncio.sleep(0.1) try: yield owner @@ -171,7 +172,7 @@ class CodexAuth: .values(payload=payload, version=CodexCredential.version + 1) ) if result.rowcount != 1: - raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.') + raise ValueError(t('ChatGPT authorization was cancelled or replaced. Please retry.')) async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response: try: @@ -186,7 +187,7 @@ class CodexAuth: 25, ) except (httpx.HTTPError, TimeoutError): - raise ValueError('ChatGPT authorization network error. Please retry.') from None + raise ValueError(t('ChatGPT authorization network error. Please retry.')) from None @staticmethod def _json(response: httpx.Response) -> dict: @@ -196,7 +197,7 @@ class CodexAuth: raise ValueError return value except ValueError: - raise ValueError('ChatGPT returned an invalid authorization response') from None + raise ValueError(t('ChatGPT returned an invalid authorization response')) from None async def status(self, context, provider: str) -> dict: workspace = await self._provider(context, provider, user=True) @@ -215,7 +216,9 @@ class CodexAuth: async with self._lease(workspace, provider) as owner: response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID}) if response.status_code != 200: - raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.') + raise ValueError( + t('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.') + ) data = self._json(response) try: code = data.get('user_code') or data['usercode'] @@ -224,7 +227,7 @@ class CodexAuth: if not isinstance(code, str) or not isinstance(device, str) or not code or not device: raise ValueError except (KeyError, ValueError, TypeError): - raise ValueError('ChatGPT returned an invalid device code') from None + raise ValueError(t('ChatGPT returned an invalid device code')) from None now = time.time() try: expiry = data.get('expires_at') @@ -241,7 +244,7 @@ class CodexAuth: raise ValueError expires_at = min(now + 900, expires_at) except (ValueError, TypeError): - raise ValueError('ChatGPT returned an invalid device code expiry') from None + raise ValueError(t('ChatGPT returned an invalid device code expiry')) from None pending = { 'authorization_id': secrets.token_urlsafe(32), 'user_code': code, @@ -306,12 +309,12 @@ class CodexAuth: if response.status_code != 200: payload.pop('pending', None) await self._save(workspace, provider, owner, payload) - raise ValueError('ChatGPT device authorization failed. Please start again.') + raise ValueError(t('ChatGPT device authorization failed. Please start again.')) data = self._json(response) if not data.get('authorization_code') or not data.get('code_verifier'): payload.pop('pending', None) await self._save(workspace, provider, owner, payload) - raise ValueError('ChatGPT returned an incomplete device authorization') + raise ValueError(t('ChatGPT returned an incomplete device authorization')) # Keep an attempt tombstone so cancel can preempt exchange, but never replay a code. pending['consumed'] = True await self._save(workspace, provider, owner, payload) @@ -326,7 +329,7 @@ class CodexAuth: }, ) if response.status_code != 200: - raise ValueError('ChatGPT token exchange failed. Please start sign-in again.') + raise ValueError(t('ChatGPT token exchange failed. Please start sign-in again.')) tokens = _tokens(self._json(response)) await self._save( workspace, @@ -384,7 +387,7 @@ class CodexAuth: payload = row['payload'] if row else {} tokens = payload.get('tokens') if not tokens or payload.get('invalid'): - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token: return tokens async with self._lease(workspace, provider, refresh=True) as owner: @@ -392,7 +395,7 @@ class CodexAuth: payload = dict(row['payload']) tokens = payload.get('tokens') if not tokens or payload.get('invalid'): - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token: return tokens response = await self._post( @@ -411,9 +414,9 @@ class CodexAuth: payload.pop('tokens', None) payload.pop('completed', None) await self._save(workspace, provider, owner, payload) - raise ValueError(LOGIN_REQUIRED) + raise ValueError(t(LOGIN_REQUIRED)) if response.status_code != 200: - raise ValueError('ChatGPT token refresh temporarily failed. Please retry.') + raise ValueError(t('ChatGPT token refresh temporarily failed. Please retry.')) refreshed = _tokens(self._json(response), tokens) payload['tokens'] = refreshed await self._save(workspace, provider, owner, payload) diff --git a/src/langbot/pkg/provider/modelmgr/codex_errors.py b/src/langbot/pkg/provider/modelmgr/codex_errors.py index a7ceb0301..78141afb7 100644 --- a/src/langbot/pkg/provider/modelmgr/codex_errors.py +++ b/src/langbot/pkg/provider/modelmgr/codex_errors.py @@ -1,10 +1,20 @@ """Explicitly safe Codex failures; never construct messages from upstream bodies.""" +from ...i18n import t +from .errors import RequesterError + class CodexProviderError(ValueError): """A known provider failure safe to expose at the HTTP boundary.""" def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'): - super().__init__(message) + ValueError.__init__(self, t(message)) + self.code = error_code + self.retryable = status_code in (500, 502, 503, 504) + self.retry_after = None + self.request_id = None + self.phase = None self.status_code = status_code self.error_code = error_code + + as_dict = RequesterError.as_dict diff --git a/src/langbot/pkg/provider/modelmgr/errors.py b/src/langbot/pkg/provider/modelmgr/errors.py index dc3b35b6f..6df4c297b 100644 --- a/src/langbot/pkg/provider/modelmgr/errors.py +++ b/src/langbot/pkg/provider/modelmgr/errors.py @@ -1,5 +1,31 @@ +from ...i18n import t + + class RequesterError(Exception): """Base class for all Requester errors.""" - def __init__(self, message: str): - super().__init__('模型请求失败: ' + message) + def __init__( + self, + message: str, + *, + code: str = 'provider_error', + status_code: int | None = None, + retryable: bool = False, + retry_after: float | None = None, + request_id: str | None = None, + phase: str | None = None, + ): + super().__init__(t('Model request failed') + ': ' + message) + self.code = code + self.status_code = status_code + self.retryable = retryable + self.retry_after = retry_after + self.request_id = request_id + self.phase = phase + + def as_dict(self) -> dict: + """Machine-readable diagnostics without upstream bodies or credentials.""" + return { + key: getattr(self, key) + for key in ('code', 'status_code', 'retryable', 'retry_after', 'request_id', 'phase') + } diff --git a/src/langbot/pkg/provider/modelmgr/http_pool.py b/src/langbot/pkg/provider/modelmgr/http_pool.py new file mode 100644 index 000000000..a29efefd9 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/http_pool.py @@ -0,0 +1,114 @@ +"""Requester-local connections, retired only after their active leases finish.""" + +from contextlib import asynccontextmanager +import asyncio +import re + +import httpx + +from .errors import RequesterError + + +class _CompletedSSEStream(httpx.AsyncByteStream): + """Finish bounded HTTP framing before SDKs stop at the SSE DONE record.""" + + _done = re.compile(rb'\ndata: ?\[DONE\]\r?\n\r?\n') + + def __init__(self, stream): + self.stream = stream + + async def __aiter__(self): + iterator = self.stream.__aiter__() + tail = b'\n' + async for chunk in iterator: + probe = tail + chunk + if self._done.search(probe): + # SDK parsers stop at [DONE] before httpcore observes EOF. This + # otherwise discards the connection on every successful stream. + # Do not drain on cancellation, truncation, or merely a finish + # reason: providers can still send usage before the DONE record. + try: + async with asyncio.timeout(0.05): + drained = 0 + async for extra in iterator: + drained += len(extra) + if drained >= 65536: + break + except (TimeoutError, httpx.HTTPError): + pass + yield chunk + return + tail = probe[-32:] + yield chunk + + async def aclose(self): + await self.stream.aclose() + + +async def _finish_sse_body(response): + if response.headers.get('content-type', '').split(';')[0].strip() == 'text/event-stream': + response.stream = _CompletedSSEStream(response.stream) + + +class RequesterHTTPPool: + def __init__(self, *, follow_redirects=False, drain_sse=False): + self.client = None + self.active = 0 + self.retired = False + self.follow_redirects = follow_redirects + self.drain_sse = drain_sse + self._creation_lock = asyncio.Lock() + + @asynccontextmanager + async def lease(self): + if self.retired: + raise RequesterError('Provider has been retired', code='provider_retired') + if self.client is None: + async with self._creation_lock: + if self.retired: + raise RequesterError('Provider has been retired', code='provider_retired') + if self.client is None: + # HTTPX otherwise reloads the same CA bundle for its direct + # transport and each environment proxy mount. Load it once, + # off the event loop, preserving HTTPX's TLS/env semantics. + # Cancellation here leaves no open HTTP client or sockets. + ssl_context = await asyncio.to_thread(httpx.create_ssl_context) + if self.retired: + raise RequesterError('Provider has been retired', code='provider_retired') + # Auth and deadlines remain request-local. No pool or TLS + # context is shared across runtime providers. + self.client = httpx.AsyncClient( + verify=ssl_context, + follow_redirects=self.follow_redirects, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=30), + event_hooks={'response': [_finish_sse_body]} if self.drain_sse else None, + ) + self.active += 1 + try: + yield self.client + finally: + self.active -= 1 + if self.retired and not self.active: + await self._close() + + async def _close(self): + client, self.client = self.client, None + if client is not None: + await client.aclose() + + async def aclose(self): + self.retired = True + if not self.active: + await self._close() + + +@asynccontextmanager +async def requester_http_client(owner): + pool = getattr(owner, '_http_pool', None) + if pool is not None: + async with pool.lease() as client: + yield client + else: + # Preserve direct/uninitialized requester use by older extensions/tests. + async with httpx.AsyncClient(follow_redirects=False) as client: + yield client diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index e86f1761b..e13464a9c 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -743,6 +743,7 @@ class ModelManager: config = { 'base_url': provider_entity.base_url, 'requester_name': provider_entity.requester, + 'request_policy': self.ap.instance_config.data.get('llm_request_policy', {}), } if provider_entity.requester == 'openai-codex': diff --git a/src/langbot/pkg/provider/modelmgr/prompt_caching.py b/src/langbot/pkg/provider/modelmgr/prompt_caching.py new file mode 100644 index 000000000..5ec55277f --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/prompt_caching.py @@ -0,0 +1,82 @@ +"""Opt-in upstream prompt caching; never cache generated answers locally.""" + +import copy +import hashlib + +from .errors import RequesterError + + +def _has_breakpoint(value): + if isinstance(value, dict): + return 'cache_control' in value or any(_has_breakpoint(item) for item in value.values()) + if isinstance(value, list): + return any(_has_breakpoint(item) for item in value) + return False + + +def apply_prompt_caching(args, state, *, provider, family): + config = args.pop('prompt_caching', None) + if config is None: + return + if not isinstance(config, dict) or set(config) - {'mode', 'retention', 'ttl'}: + raise RequesterError('Invalid prompt_caching fields', code='invalid_policy') + mode = config.get('mode', 'provider_default') + if mode not in ('provider_default', 'openai', 'anthropic'): + raise RequesterError('Unsupported prompt_caching mode', code='invalid_policy') + if mode == 'provider_default': + if set(config) - {'mode'}: + raise RequesterError('Cache settings require an explicit mode', code='invalid_policy') + return + if mode == 'openai': + if provider not in ('openai', 'openrouter') or family not in ('openai', 'openrouter'): + raise RequesterError('OpenAI caching requires an OpenAI-compatible model', code='invalid_policy') + if 'ttl' in config or config.get('retention') not in (None, 'in_memory', '24h'): + raise RequesterError('Invalid OpenAI cache retention', code='invalid_policy') + body = copy.deepcopy(args.get('extra_body') or {}) + if not isinstance(body, dict): + raise RequesterError('extra_body must be an object', code='invalid_policy') + # Bypass LiteLLM's model-specific parameter filter for explicit opt-in + # caching fields as well as generated hints. The SDK's extra_body wins + # when the same native field appears in both locations. + for key in ('prompt_cache_key', 'prompt_cache_retention', 'prompt_cache_options'): + if key in args: + body.setdefault(key, args.pop(key)) + # Explicit provider-native settings win. A key is a routing hint only; + # it neither enables caching nor guarantees a hit. + if ( + state + and state.conversation_id + and not any(key in body for key in ('prompt_cache_key', 'prompt_cache_options')) + ): + scope = f'{state.conversation_id}:{args["model"]}' + body['prompt_cache_key'] = 'lb-' + hashlib.sha256(scope.encode()).hexdigest() + if config.get('retention') and not any( + key in location for key in ('prompt_cache_retention', 'prompt_cache_options') for location in (args, body) + ): + body['prompt_cache_retention'] = config['retention'] + if body: + args['extra_body'] = body + return + if family != 'anthropic': + raise RequesterError('Anthropic caching requires an Anthropic model', code='invalid_policy') + if 'retention' in config or config.get('ttl', '5m') not in ('5m', '1h'): + raise RequesterError('Invalid Anthropic cache TTL', code='invalid_policy') + # Existing manual or automatic breakpoints are authoritative. Do not exceed + # a provider's breakpoint limit or change its TTL ordering. + if _has_breakpoint(args): + return + messages = copy.deepcopy(args['messages']) + boundary = None + for message in messages: + if message.get('role') not in ('system', 'developer'): + break + content = message.get('content') + if isinstance(content, str) and content: + message['content'] = [{'type': 'text', 'text': content}] + if isinstance(message.get('content'), list): + for part in message['content']: + if isinstance(part, dict) and part.get('type') == 'text' and part.get('text'): + boundary = part + if boundary is not None: + boundary['cache_control'] = {'type': 'ephemeral', 'ttl': config.get('ttl', '5m')} + args['messages'] = messages diff --git a/src/langbot/pkg/provider/modelmgr/reasoning.py b/src/langbot/pkg/provider/modelmgr/reasoning.py index ecc6c38e2..dead45620 100644 --- a/src/langbot/pkg/provider/modelmgr/reasoning.py +++ b/src/langbot/pkg/provider/modelmgr/reasoning.py @@ -48,20 +48,27 @@ _CONFLICTING_EXTRA_BODY_ARGS = { } -def normalize_reasoning_config(value: typing.Any) -> dict[str, str]: +def normalize_reasoning_config(value: typing.Any) -> dict[str, typing.Any]: """Return the canonical model reasoning configuration.""" if value is None: return dict(DEFAULT_REASONING_CONFIG) if not isinstance(value, dict): raise ValueError('reasoning_config must be an object') - unknown_fields = set(value) - {'level'} + unknown_fields = set(value) - {'level', 'budget_tokens'} if unknown_fields: raise ValueError(f'Unsupported reasoning_config fields: {", ".join(sorted(unknown_fields))}') - level = value.get('level', 'provider_default') + budget = value.get('budget_tokens') + level = value.get('level', 'enabled' if budget is not None else 'provider_default') if level not in REASONING_LEVELS: raise ValueError(f'Unsupported reasoning level: {level}') + if budget is not None: + if type(budget) is not int or budget <= 0: + raise ValueError('budget_tokens must be a positive integer') + if level != 'enabled': + raise ValueError('budget_tokens requires level enabled; do not combine a token budget with an effort level') + return {'level': level, 'budget_tokens': budget} return {'level': typing.cast(str, level)} @@ -69,7 +76,7 @@ def validate_reasoning_config( value: typing.Any, abilities: typing.Iterable[str] | None, extra_args: typing.Any, -) -> dict[str, str]: +) -> dict[str, typing.Any]: """Validate a model-facing reasoning config and conflicting raw arguments.""" config = normalize_reasoning_config(value) if config['level'] == 'provider_default': @@ -101,7 +108,13 @@ def validate_reasoning_capabilities( model_name: str, ) -> None: """Ensure an explicit reasoning level can be honored by the requester.""" - level = normalize_reasoning_config(config)['level'] + config = normalize_reasoning_config(config) + level = config['level'] + if 'budget_tokens' in config: + limits = capabilities.get('budget_tokens') + if not isinstance(limits, dict) or not limits['min'] <= config['budget_tokens'] <= limits['max']: + raise ValueError(f'Unsupported reasoning token budget for model {model_name}: {config["budget_tokens"]}') + return if level == 'provider_default': return @@ -154,3 +167,17 @@ def model_with_reasoning_level( scoped_model = copy.copy(model) scoped_model.reasoning_config_override = copy.deepcopy(config) return scoped_model + + +def resolve_reasoning_config(model, extra_args=None): + """Runner policy wins; a call object replaces, rather than merges, a model policy.""" + value = getattr(model.model_entity, 'reasoning_config', None) + if value is not None and not isinstance(value, (dict, str, int, float, bool, list, tuple)): + value = None + for args in (model.model_entity.extra_args, extra_args): + if isinstance(args, dict) and 'reasoning_config' in args: + value = args['reasoning_config'] + override = getattr(model, 'reasoning_config_override', None) + if override is not None: + value = override + return normalize_reasoning_config(value) diff --git a/src/langbot/pkg/provider/modelmgr/request_policy.py b/src/langbot/pkg/provider/modelmgr/request_policy.py new file mode 100644 index 000000000..02438d8bd --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/request_policy.py @@ -0,0 +1,173 @@ +"""Host-owned LLM deadlines, retries and additive diagnostics. + +No SDK wire shape changes: request_policy is a reserved model/call extra argument. +Context-local state lets adapters report raw events without sharing mutable state. +""" + +from __future__ import annotations + +import asyncio +from contextvars import ContextVar +from dataclasses import dataclass, fields +from email.utils import parsedate_to_datetime +import hashlib +import json +import math +import random +import time + +from .errors import RequesterError +from .codex_errors import CodexProviderError + +STRUCTURED_ERRORS = (RequesterError, CodexProviderError) + + +@dataclass +class RequestPolicy: + retries: int = 0 + total_timeout: float = 300 + first_event_timeout: float = 120 + stream_idle_timeout: float = 120 + connect_timeout: float = 10 + read_timeout: float = 120 + write_timeout: float = 30 + pool_timeout: float = 10 + backoff_base: float = 0.5 + backoff_max: float = 8 + attribution: bool = True + conversation_header: str | None = None + + @classmethod + def resolve(cls, *layers): + values = {} + names = {field.name for field in fields(cls)} + for layer in layers: + if not isinstance(layer, dict): + continue + # Preserve the legacy per-model/call retry knob, including zero. + if 'max_retries' in layer: + values['retries'] = layer['max_retries'] + if 'num_retries' in layer: + values['retries'] = layer['num_retries'] + if isinstance(layer.get('timeout'), (int, float)): + values['read_timeout'] = layer['timeout'] + values['first_event_timeout'] = layer['timeout'] + values['stream_idle_timeout'] = layer['timeout'] + policy = layer.get('request_policy', {}) + if not isinstance(policy, dict) or set(policy) - names: + raise RequesterError('Invalid request_policy fields', code='invalid_policy') + values.update(policy) + policy = cls(**values) + for field in fields(cls): + value = getattr(policy, field.name) + if field.name == 'conversation_header': + if value not in (None, 'x-session-id', 'x-litellm-session-id', 'x-langbot-conversation-id'): + raise RequesterError('Unsupported conversation header', code='invalid_policy') + elif field.name == 'attribution': + if not isinstance(value, bool): + raise RequesterError('attribution must be boolean', code='invalid_policy') + elif field.name == 'retries': + if type(value) is not int or not 0 <= value <= 5: + raise RequesterError('retries must be an integer from 0 to 5', code='invalid_policy') + elif ( + isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0 + ): + raise RequesterError(f'{field.name} must be finite and positive', code='invalid_policy') + return policy + + +class CallState: + def __init__(self, policy, conversation_id=None): + self.policy = policy + self.conversation_id = conversation_id + self.start = time.perf_counter() + self.attempt_start = self.start + self.metrics = {'attempts': 0, 'retry_wait_ms': 0} + self.raw_started = False + self.error = None + self.usage = None + self.prepared = {} + + def mark(self, name): + self.metrics.setdefault(name, round((time.perf_counter() - self.start) * 1000, 3)) + + async def wait(self, operation, phase, limit): + remaining = self.policy.total_timeout - (time.perf_counter() - self.start) + if phase == 'first_event': + limit = min(limit, self.policy.first_event_timeout - (time.perf_counter() - self.attempt_start)) + token = current_call.set(self) + try: + # timeout(0) only cancels at a scheduling point. Buffered SDK + # responses can complete synchronously, so enforce expired budgets + # before starting another operation as well. + if min(limit, remaining) <= 0: + raise TimeoutError + async with asyncio.timeout(max(0, min(limit, remaining))): + return await operation() + except TimeoutError as exc: + raise RequesterError( + 'LLM request timed out', code='timeout', retryable=True, phase='total' if remaining <= limit else phase + ) from exc + finally: + current_call.reset(token) + + async def retry(self, error): + self.error = error.as_dict() if isinstance(error, STRUCTURED_ERRORS) else {'code': 'provider_error'} + self.metrics.setdefault('attempt_errors', []).append(dict(self.error)) + if not isinstance(error, STRUCTURED_ERRORS) or not error.retryable or self.raw_started: + return False + if self.metrics['attempts'] > self.policy.retries: + return False + delay = random.uniform( + 0, min(self.policy.backoff_max, self.policy.backoff_base * 2 ** (self.metrics['attempts'] - 1)) + ) + delay = max(delay, error.retry_after or 0) + remaining = self.policy.total_timeout - (time.perf_counter() - self.start) + if delay >= remaining: + return False + started = time.perf_counter() + await asyncio.sleep(delay) + self.metrics['retry_wait_ms'] += round((time.perf_counter() - started) * 1000, 3) + return True + + +current_call: ContextVar[CallState | None] = ContextVar('llm_call', default=None) + + +def opaque_conversation_id(query, context): + if query is None: + return None + session = getattr(query, 'session', None) + session_id = getattr(session, 'session_id', None) + if not isinstance(session_id, (str, int)): + launcher = getattr(query, 'launcher_id', None) + if not isinstance(launcher, (str, int)): + return None + launcher_type = getattr(query, 'launcher_type', '') + session_id = [str(getattr(launcher_type, 'value', launcher_type)), str(launcher)] + scope = [context.workspace_uuid, query.bot_uuid, query.pipeline_uuid, session_id] + return 'lb-' + hashlib.sha256(json.dumps(scope, ensure_ascii=True).encode()).hexdigest() + + +def error_metadata(exc): + status = getattr(exc, 'status_code', None) + status = status if isinstance(status, int) else None + response = getattr(exc, 'response', None) + headers = getattr(response, 'headers', {}) + headers = headers if hasattr(headers, 'get') else {} + retry_after = None + raw = headers.get('retry-after') + if raw: + try: + retry_after = float(raw) + except (ValueError, TypeError): + try: + retry_after = parsedate_to_datetime(raw).timestamp() - time.time() + except (ValueError, TypeError, OverflowError): + pass + if retry_after is not None: + retry_after = max(0, retry_after) if math.isfinite(retry_after) else None + request_id = headers.get('x-request-id') or headers.get('request-id') + if not isinstance(request_id, str): + request_id = None + return {'status_code': status, 'retry_after': retry_after, 'request_id': request_id[:256] if request_id else None} diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py index 069c9981e..1aa75e4d9 100644 --- a/src/langbot/pkg/provider/modelmgr/requester.py +++ b/src/langbot/pkg/provider/modelmgr/requester.py @@ -1,6 +1,9 @@ from __future__ import annotations +from contextlib import aclosing + import abc +import asyncio import typing import time @@ -11,6 +14,7 @@ from ...workspace.errors import WorkspaceInvariantError import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from . import token from . import reasoning +from . import request_policy import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -98,6 +102,48 @@ class RuntimeProvider: raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent') return execution_context + def _call_state(self, query, model, extra_args, context): + config = getattr(self.requester, 'requester_cfg', {}) + policy = request_policy.RequestPolicy.resolve(config, model.model_entity.extra_args, extra_args) + if query is not None: + if query.variables is None: + query.variables = {} + query.variables.pop(LLM_USAGE_QUERY_VARIABLE, None) + query.variables.pop(STREAM_USAGE_QUERY_VARIABLE, None) + return request_policy.CallState(policy, request_policy.opaque_conversation_id(query, context)) + + async def _record_call(self, query, model, state, status, error): + state.mark('total_ms') + if query is None: + return + stream_usage = query.variables.pop(STREAM_USAGE_QUERY_VARIABLE, None) + usage = state.usage if state.usage is not None else stream_usage + if usage is not None: + _store_llm_usage(query, usage) + details = {'timing': dict(state.metrics), 'usage': usage, 'error': error} + query.variables['_llm_diagnostics'] = details + try: + from ...pipeline import monitoring_helper + + await monitoring_helper.MonitoringHelper.record_llm_call( + ap=self.requester.ap, + query=query, + bot_id=query.bot_uuid or 'unknown', + bot_name=query.variables.get('_monitoring_bot_name', 'Unknown'), + pipeline_id=query.pipeline_uuid or 'unknown', + pipeline_name=query.variables.get('_monitoring_pipeline_name', 'Unknown'), + model_name=model.model_entity.name, + input_tokens=(usage or {}).get('prompt_tokens', 0) or 0, + output_tokens=(usage or {}).get('completion_tokens', 0) or 0, + duration_ms=int(state.metrics['total_ms']), + status=status, + error_message=error.get('message') if error else None, + message_id=query.variables.get('_monitoring_message_id'), + details=details, + ) + except Exception as exc: + self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {exc}') + async def invoke_llm( self, query: pipeline_query.Query | None, @@ -108,79 +154,46 @@ class RuntimeProvider: remove_think: bool = False, execution_context: ExecutionContext | None = None, ) -> provider_message.Message: - """Bridge method for invoking LLM with monitoring""" - invocation_context = self._resolve_llm_execution_context(query, execution_context) - self._validate_invocation(model, invocation_context) - # Start timing for monitoring - start_time = time.time() - input_tokens = 0 - output_tokens = 0 - status = 'success' - error_message = None - + """Preserve the legacy Message return while owning retries and deadlines.""" + context = self._resolve_llm_execution_context(query, execution_context) + self._validate_invocation(model, context) + state = self._call_state(query, model, extra_args, context) + status, error = 'success', None try: - # Call the underlying requester - result = await self.requester.invoke_llm( - query=query, - model=model, - messages=messages, - funcs=funcs, - extra_args=extra_args, - remove_think=remove_think, - ) - - # Try to extract token usage if the requester returns it - # For requesters that return tuple (message, usage_info) - if isinstance(result, tuple): - msg, usage_info = result - if usage_info: - _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 - - except Exception as e: + while True: + state.metrics['attempts'] += 1 + state.attempt_start = time.perf_counter() + try: + result = await state.wait( + lambda: self.requester.invoke_llm( + query=query, + model=model, + messages=messages, + funcs=funcs, + extra_args=extra_args, + remove_think=remove_think, + ), + 'response', + state.policy.total_timeout, + ) + state.mark('response_completed_ms') + if isinstance(result, tuple): + message, state.usage = result + return message + return result + except Exception as exc: + if not await state.retry(exc): + raise + except (asyncio.CancelledError, GeneratorExit): + status, error = 'error', {'code': 'cancelled', 'message': 'LLM invocation cancelled'} + raise + except Exception as exc: status = 'error' - error_message = str(e) + error = exc.as_dict() if isinstance(exc, request_policy.STRUCTURED_ERRORS) else {'code': 'provider_error'} + error['message'] = str(exc) raise finally: - # Record LLM call monitoring data (only if query is provided) - if query is not None: - duration_ms = int((time.time() - start_time) * 1000) - - # Import monitoring helper - try: - from ...pipeline import monitoring_helper - - # Get monitoring metadata from query variables - if query.variables: - 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') - else: - bot_name = 'Unknown' - pipeline_name = 'Unknown' - message_id = None - - await monitoring_helper.MonitoringHelper.record_llm_call( - ap=self.requester.ap, - query=query, - bot_id=query.bot_uuid or 'unknown', - bot_name=bot_name, - pipeline_id=query.pipeline_uuid or 'unknown', - pipeline_name=pipeline_name, - model_name=model.model_entity.name, - input_tokens=input_tokens, - output_tokens=output_tokens, - duration_ms=duration_ms, - status=status, - error_message=error_message, - message_id=message_id, - ) - except Exception as monitor_err: - self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}') + await self._record_call(query, model, state, status, error) async def invoke_llm_stream( self, @@ -191,78 +204,66 @@ class RuntimeProvider: extra_args: dict[str, typing.Any] = {}, remove_think: bool = False, execution_context: ExecutionContext | None = None, - ) -> provider_message.MessageChunk: - """Bridge method for invoking LLM stream with monitoring""" - invocation_context = self._resolve_llm_execution_context(query, execution_context) - self._validate_invocation(model, invocation_context) - # Start timing for monitoring - start_time = time.time() - status = 'success' - error_message = None - input_tokens = 0 - output_tokens = 0 - + ) -> typing.AsyncIterator[provider_message.MessageChunk]: + """Never retry after a raw upstream event or a legacy adapter chunk.""" + context = self._resolve_llm_execution_context(query, execution_context) + self._validate_invocation(model, context) + state = self._call_state(query, model, extra_args, context) + status, error = 'success', None try: - # Stream the response - async for chunk in self.requester.invoke_llm_stream( - query=query, - model=model, - messages=messages, - funcs=funcs, - extra_args=extra_args, - 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: + while True: + state.metrics['attempts'] += 1 + state.attempt_start = time.perf_counter() + try: + async with aclosing( + self.requester.invoke_llm_stream( + query=query, + model=model, + messages=messages, + funcs=funcs, + extra_args=extra_args, + remove_think=remove_think, + ) + ) as stream: + while True: + try: + chunk = await state.wait( + lambda: anext(stream), + 'total' + if getattr(self.requester, 'reports_raw_timing', False) is True + else ('stream_idle' if state.raw_started else 'first_event'), + state.policy.total_timeout + if getattr(self.requester, 'reports_raw_timing', False) is True + else ( + state.policy.stream_idle_timeout + if state.raw_started + else state.policy.first_event_timeout + ), + ) + except StopAsyncIteration: + break + if chunk is None: + continue + state.raw_started = True + state.mark('first_event_ms') + if chunk.content: + state.mark('first_visible_chunk_ms') + yield chunk + state.mark('response_completed_ms') + return + except Exception as exc: + if not await state.retry(exc): + raise + except (asyncio.CancelledError, GeneratorExit): + status, error = 'error', {'code': 'cancelled', 'message': 'LLM stream cancelled'} + raise + except Exception as exc: status = 'error' - error_message = str(e) + error = exc.as_dict() if isinstance(exc, request_policy.STRUCTURED_ERRORS) else {'code': 'provider_error'} + error['message'] = str(exc) raise finally: - # Record LLM call monitoring data (only if query is provided) - if query is not None: - duration_ms = int((time.time() - start_time) * 1000) - - # Import monitoring helper - try: - from ...pipeline import monitoring_helper - - # Get monitoring metadata from query variables - if query.variables: - 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') - else: - bot_name = 'Unknown' - pipeline_name = 'Unknown' - message_id = None - - await monitoring_helper.MonitoringHelper.record_llm_call( - ap=self.requester.ap, - query=query, - bot_id=query.bot_uuid or 'unknown', - bot_name=bot_name, - pipeline_id=query.pipeline_uuid or 'unknown', - pipeline_name=pipeline_name, - model_name=model.model_entity.name, - input_tokens=input_tokens, - output_tokens=output_tokens, - duration_ms=duration_ms, - status=status, - error_message=error_message, - message_id=message_id, - ) - except Exception as monitor_err: - self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}') + await self._record_call(query, model, state, status, error) async def invoke_embedding( self, @@ -378,7 +379,7 @@ class RuntimeLLMModel: provider: RuntimeProvider """提供商实例""" - reasoning_config_override: dict[str, str] | None + reasoning_config_override: dict[str, typing.Any] | None """Request-scoped reasoning policy supplied by the active pipeline.""" def __init__( @@ -386,7 +387,7 @@ class RuntimeLLMModel: execution_context: ExecutionContext, model_entity: persistence_model.LLMModel, provider: RuntimeProvider, - reasoning_config_override: dict[str, str] | None = None, + reasoning_config_override: dict[str, typing.Any] | None = None, ): _ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model') if model_entity.workspace_uuid != execution_context.workspace_uuid: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/codex.py b/src/langbot/pkg/provider/modelmgr/requesters/codex.py index 5b793ef75..728382415 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/codex.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/codex.py @@ -7,12 +7,16 @@ import json import secrets import time from collections import OrderedDict +from contextlib import AsyncExitStack, aclosing import httpx import langbot import langbot_plugin.api.entities.builtin.provider.message as pm from .. import requester, reasoning +from ..request_policy import current_call +from ..http_pool import RequesterHTTPPool, requester_http_client +from ..errors import RequesterError from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED from ..codex_errors import CodexProviderError @@ -21,7 +25,8 @@ async def sse_events(response): """Decode SSE records, including CRLF, comments, and multiline data.""" data = [] size = 0 - async for line in response.aiter_lines(): + lines = response.aiter_lines() + async for line in lines: if not line: if data: text = '\n'.join(data) @@ -33,6 +38,18 @@ async def sse_events(response): raise ValueError except ValueError: raise ValueError('Codex returned an invalid stream event') from None + if event.get('type') == 'response.completed': + # Finish the HTTP body so a healthy connection can return + # to the pool. Never wait indefinitely for a broken SSE + # server after it has already sent the terminal result. + try: + async with asyncio.timeout(0.05): + async for _ in lines: + pass + except (TimeoutError, httpx.HTTPError): + pass + yield event + return yield event data, size = [], 0 elif line.startswith('data:'): @@ -85,6 +102,7 @@ def _tool(item): def _usage(response): usage = response.get('usage') or {} return { + **usage, 'prompt_tokens': usage.get('input_tokens', 0), 'completion_tokens': usage.get('output_tokens', 0), 'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)), @@ -94,7 +112,10 @@ def _usage(response): class CodexRequester(requester.ProviderAPIRequester): + reports_raw_timing = True + async def initialize(self): + self._http_pool = RequesterHTTPPool() self.auth = CodexAuth(self.ap) self.workspace = self.requester_cfg['workspace_uuid'] self.provider = self.requester_cfg['provider_uuid'] @@ -104,6 +125,8 @@ class CodexRequester(requester.ProviderAPIRequester): async def aclose(self): self._replay.clear() + if getattr(self, '_http_pool', None) is not None: + await self._http_pool.aclose() def get_reasoning_capabilities(self, model): return { @@ -184,6 +207,8 @@ class CodexRequester(requester.ProviderAPIRequester): def _body(self, query, model, messages, funcs, extra_args, tokens): args = {**(model.model_entity.extra_args or {}), **(extra_args or {})} + args.pop('request_policy', None) + args.pop('reasoning_config', None) # Never permit credentials, transport overrides, store/history or arbitrary # SDK kwargs to be smuggled through model advanced parameters. allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'} @@ -235,13 +260,11 @@ class CodexRequester(requester.ProviderAPIRequester): 'stream': True, 'include': ['reasoning.encrypted_content'], } - reasoning_config = getattr(model, 'reasoning_config_override', None) - if reasoning_config is None: - reasoning_config = getattr(model.model_entity, 'reasoning_config', None) - level = reasoning.normalize_reasoning_config(reasoning_config)['level'] + reasoning_config = reasoning.resolve_reasoning_config(model, extra_args) + level = reasoning_config['level'] if level != 'provider_default': reasoning.validate_reasoning_capabilities( - {'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name + reasoning_config, self.get_reasoning_capabilities(model), model.model_entity.name ) body['reasoning'] = {'effort': level, 'summary': 'auto'} if funcs: @@ -259,13 +282,39 @@ class CodexRequester(requester.ProviderAPIRequester): async def _events(self, query, model, messages, funcs, extra_args): tokens = await self.auth.access(self.workspace, self.provider) + state = current_call.get() + timeout = 120 + if state: + p = state.policy + timeout = httpx.Timeout( + connect=p.connect_timeout, read=p.read_timeout, write=p.write_timeout, pool=p.pool_timeout + ) try: - async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client: + async with ( + asyncio.timeout(None if state else 300), + requester_http_client(self) as client, + ): for attempt in range(2): body = self._body(query, model, messages, funcs, extra_args, tokens) - async with client.stream( - 'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True) - ) as response: + if state: + state.mark('request_prepared_ms') + state.mark('request_started_ms') + async with AsyncExitStack() as stack: + response_context = client.stream( + 'POST', + BASE_URL + '/responses', + json=body, + headers=self._headers(tokens, stream=True), + timeout=timeout, + ) + if state: + response = await state.wait( + lambda: stack.enter_async_context(response_context), + 'first_event', + state.policy.first_event_timeout, + ) + else: + response = await stack.enter_async_context(response_context) if response.status_code == 401 and attempt == 0: tokens = await self.auth.access( self.workspace, self.provider, rejected_token=tokens['access_token'] @@ -273,85 +322,117 @@ class CodexRequester(requester.ProviderAPIRequester): continue if response.status_code != 200: raise await self._response_error(response) - async for event in sse_events(response): + if state: + state.mark('response_open_ms') + events = sse_events(response) + while True: + try: + if state: + event = await state.wait( + lambda: anext(events), + 'stream_idle' if state.raw_started else 'first_event', + state.policy.stream_idle_timeout + if state.raw_started + else state.policy.first_event_timeout, + ) + else: + event = await anext(events) + except StopAsyncIteration: + break + if state: + state.raw_started = True + state.mark('first_event_ms') + kind = event.get('type', '') + if kind == 'response.output_text.delta' and event.get('delta'): + state.mark('first_content_ms') + elif 'reasoning' in kind and event.get('delta'): + state.mark('first_reasoning_ms') yield event, tokens return - except (httpx.HTTPError, TimeoutError): - raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None + except (httpx.HTTPError, TimeoutError) as exc: + raise RequesterError( + 'ChatGPT Codex network error or timeout. Please retry.', + code='timeout' if isinstance(exc, (httpx.TimeoutException, TimeoutError)) else 'connection', + retryable=True, + ) from None async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out): text = '' seen_calls = set() output_items = {} response_id = None - async for event, tokens in self._events(query, model, messages, funcs, extra_args): - kind = event.get('type') - response = event.get('response') or {} - response_id = response.get('id') or response_id - if kind in ('error', 'response.failed', 'response.incomplete'): - raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.') - if kind == 'response.output_text.delta': - delta = event.get('delta', '') - text += delta - yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id) - elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'): - if not remove_think: - yield pm.MessageChunk( - role='assistant', - content='', - provider_specific_fields={'reasoning_content': event.get('delta', '')}, - ) - elif kind == 'response.output_item.done': - item = event.get('item') or {} - output_items[event.get('output_index', len(output_items))] = item - if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls: - seen_calls.add(item.get('call_id')) - yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)]) - elif kind in ('response.completed', 'response.done'): - if response.get('status') not in (None, 'completed'): - raise CodexProviderError('ChatGPT Codex response was not completed') - output = response.get('output') or [output_items[k] for k in sorted(output_items)] - for item in output: + async with aclosing(self._events(query, model, messages, funcs, extra_args)) as events: + async for event, tokens in events: + kind = event.get('type') + response = event.get('response') or {} + response_id = response.get('id') or response_id + if kind in ('error', 'response.failed', 'response.incomplete'): + raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.') + if kind == 'response.output_text.delta': + delta = event.get('delta', '') + text += delta + yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id) + elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'): + if not remove_think: + yield pm.MessageChunk( + role='assistant', + content='', + provider_specific_fields={'reasoning_content': event.get('delta', '')}, + ) + elif kind == 'response.output_item.done': + item = event.get('item') or {} + output_items[event.get('output_index', len(output_items))] = item if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls: seen_calls.add(item.get('call_id')) yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)]) - # Some servers send only the terminal output, without text deltas. - final_text = ''.join( - p.get('text', '') - for item in output - if item.get('type') == 'message' - for p in item.get('content', []) - if p.get('type') == 'output_text' - ) - if not text and final_text: - text = final_text - yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id) - usage_out.update(_usage(response)) - if query is not None: - if query.variables is None: - query.variables = {} - query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out) - fields = None - if query is not None and output: - handle = secrets.token_urlsafe(24) - self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output) - while len(self._replay) > 64: - self._replay.popitem(last=False) - fields = {'codex_replay_id': handle} - yield pm.MessageChunk( - role='assistant', - content='', - all_content=text, - is_final=True, - resp_message_id=response_id, - provider_specific_fields=fields, - ) - return + elif kind in ('response.completed', 'response.done'): + if response.get('status') not in (None, 'completed'): + raise CodexProviderError('ChatGPT Codex response was not completed') + output = response.get('output') or [output_items[k] for k in sorted(output_items)] + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls: + seen_calls.add(item.get('call_id')) + yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)]) + # Some servers send only the terminal output, without text deltas. + final_text = ''.join( + p.get('text', '') + for item in output + if item.get('type') == 'message' + for p in item.get('content', []) + if p.get('type') == 'output_text' + ) + if not text and final_text: + text = final_text + yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id) + usage_out.update(_usage(response)) + if current_call.get(): + current_call.get().usage = dict(usage_out) + if query is not None: + if query.variables is None: + query.variables = {} + query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out) + fields = None + if query is not None and output: + handle = secrets.token_urlsafe(24) + self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output) + while len(self._replay) > 64: + self._replay.popitem(last=False) + fields = {'codex_replay_id': handle} + yield pm.MessageChunk( + role='assistant', + content='', + all_content=text, + is_final=True, + resp_message_id=response_id, + provider_specific_fields=fields, + ) + return raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.') async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False): - async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}): - yield chunk + async with aclosing(self._chunks(query, model, messages, funcs, extra_args, remove_think, {})) as chunks: + async for chunk in chunks: + yield chunk async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False): usage = {} @@ -376,11 +457,12 @@ class CodexRequester(requester.ProviderAPIRequester): async def scan_models(self, api_key=None): tokens = await self.auth.access(self.workspace, self.provider) try: - async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client: + async with asyncio.timeout(90), requester_http_client(self) as client: for attempt in range(2): response = await client.get( BASE_URL + '/models', params={'client_version': langbot.__version__}, + timeout=30, headers=self._headers(tokens), ) if response.status_code == 401 and attempt == 0: diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py index 0bbaa1db5..6f1de0e86 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py @@ -2,10 +2,26 @@ from __future__ import annotations +from ....i18n import t + import typing +import copy +import time +import os +from collections import OrderedDict +from contextlib import asynccontextmanager +from openai import AsyncOpenAI +from ..http_pool import RequesterHTTPPool +from ..prompt_caching import apply_prompt_caching +import asyncio +import httpx +from urllib.parse import urlsplit +from ..request_policy import current_call, RequestPolicy, error_metadata import litellm from litellm import acompletion, aembedding, arerank +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.secret_managers.main import get_secret, get_secret_bool from .. import errors, reasoning, requester from ....utils import httpclient @@ -14,8 +30,43 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message +class _LeasedHTTPHandler(AsyncHTTPHandler): + """Adapt a leased transport without LiteLLM's hidden connection-error retry.""" + + def __init__(self, client, timeout): + self.client = client + self.timeout = timeout + self.event_hooks = None + self.client_alias = 'langbot' + + async def post(self, url, *, timeout=None, stream=False, logging_obj=None, **kwargs): + if isinstance(kwargs.get('data'), (str, bytes)): + kwargs['content'] = kwargs.pop('data') + request = self.client.build_request('POST', url, timeout=timeout or self.timeout, **kwargs) + response = await self.client.send(request, stream=stream) + try: + response.raise_for_status() + except BaseException: + await response.aclose() + raise + return response + + async def close(self): + pass # The requester's lease owns the transport. + + async def __aexit__(self, *args): + pass + + def __del__(self): + pass + + class _ThinkStripState: - """Stateful filter that drops think blocks across chunks.""" + """Drop explicit think blocks, buffering only possible split tag prefixes. + + Untagged text must stream immediately: an orphan closing tag cannot identify + earlier text as reasoning without delaying every ordinary answer until EOF. + """ _THINK_OPEN = '' _THINK_CLOSE = '' @@ -30,7 +81,6 @@ class _ThinkStripState: self._open_tags = tuple(open_tag for open_tag, _close_tag in self._pairs) self._buf = '' self._close_tag: str | None = None - self._pending_initial = True def feed(self, chunk: str) -> str: """Feed a streaming delta and return user-visible content.""" @@ -63,7 +113,6 @@ class _ThinkStripState: if close_idx != -1: self._close_tag = None self._buf = '' - self._pending_initial = False return self._process_visible_text(text[close_idx + len(close_tag) :]) self._buf = self._close_prefix(text, close_tag) @@ -74,37 +123,14 @@ class _ThinkStripState: index = 0 while index < len(text): - if self._pending_initial: - open_idx, open_tag, close_tag = self._find_next_open(text, index) - orphan_close_idx, orphan_close_tag = self._find_next_close(text, index) - - if orphan_close_idx != -1 and (open_idx == -1 or orphan_close_idx < open_idx): - self._pending_initial = False - index = orphan_close_idx + len(orphan_close_tag) - continue - - if open_idx == -1: - self._buf = text[index:] - return ''.join(out) - - if open_idx > index: - self._pending_initial = False - out.append(text[index:open_idx]) - index = open_idx - continue - open_idx, open_tag, close_tag = self._find_next_open(text, index) if open_idx == -1: emit_end = self._visible_emit_end(text, index) out.append(text[index:emit_end]) - if emit_end > index: - self._pending_initial = False self._buf = text[emit_end:] return ''.join(out) out.append(text[index:open_idx]) - if open_idx > index: - self._pending_initial = False body_start = open_idx + len(open_tag) close_idx = text.find(close_tag, body_start) if close_idx == -1: @@ -112,7 +138,6 @@ class _ThinkStripState: self._buf = self._close_prefix(text[body_start:], close_tag) return ''.join(out) - self._pending_initial = False index = close_idx + len(close_tag) self._buf = '' @@ -130,16 +155,6 @@ class _ThinkStripState: best_close = close_tag return best_idx, best_open, best_close - def _find_next_close(self, text: str, start: int) -> tuple[int, str]: - best_idx = -1 - best_close = '' - for _open_tag, close_tag in self._pairs: - idx = text.find(close_tag, start) - if idx != -1 and (best_idx == -1 or idx < best_idx): - best_idx = idx - best_close = close_tag - return best_idx, best_close - def _visible_emit_end(self, text: str, start: int) -> int: visible = text[start:] limit = min(len(visible), max(len(open_tag) for open_tag in self._open_tags) - 1) @@ -162,6 +177,8 @@ class _ThinkStripState: class LiteLLMRequester(requester.ProviderAPIRequester): """LiteLLM unified API requester supporting chat, embedding, and rerank.""" + reports_raw_timing = True + _EMBEDDING_MODEL_HINTS = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding') _RERANK_MODEL_HINTS = ('rerank', 're-rank', 're_rank') _QWEN_DEDICATED_THINKING_MODELS = frozenset( @@ -210,9 +227,83 @@ class LiteLLMRequester(requester.ProviderAPIRequester): async def initialize(self): """Initialize LiteLLM client settings.""" - # LiteLLM doesn't require explicit client initialization - # Configuration is passed per-request via litellm params - pass + self._http_pool = RequesterHTTPPool( + follow_redirects=True, drain_sse=self._get_custom_llm_provider() == 'openai' + ) + + async def aclose(self): + if getattr(self, '_http_pool', None) is not None: + await self._http_pool.aclose() + if hasattr(self, '_metadata_cache'): + self._metadata_cache.clear() + + @asynccontextmanager + async def _client_args(self, args): + pool = getattr(self, '_http_pool', None) + provider = args.get('custom_llm_provider') or self._get_custom_llm_provider() + if pool is None or provider not in ('openai', 'openrouter') or args.get('client') is not None: + yield args + return + # Preserve explicitly customized LiteLLM transports and TLS settings. + if ( + any(args.get(key) is not None for key in ('shared_session', 'http_client', 'ssl_verify', 'ssl_certificate')) + or getattr(litellm, 'aclient_session', None) is not None + or getattr(litellm, 'ssl_verify', True) is not True + or getattr(litellm, 'ssl_certificate', None) + or getattr(litellm, 'force_ipv4', False) + or any(name in os.environ for name in ('SSL_VERIFY', 'SSL_CERTIFICATE')) + ): + yield args + return + if not args['model'].startswith(f'{provider}/'): + yield args # A caller redirected the model; let LiteLLM resolve its transport. + return + model_name = args['model'].removeprefix(f'{provider}/') + if provider == 'openrouter' and model_name in litellm.open_ai_chat_completion_models: + yield args # Preserve LiteLLM's alternate bare-model routing. + return + transport_started = time.perf_counter() + async with pool.lease() as client: + state = current_call.get() + if state: + state.metrics['transport_setup_ms'] = round( + state.metrics.get('transport_setup_ms', 0) + (time.perf_counter() - transport_started) * 1000, 3 + ) + if provider == 'openrouter' or get_secret_bool('EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER'): + yield {**args, 'client': _LeasedHTTPHandler(client, args.get('timeout', 120))} + return + # A fresh lightweight SDK wrapper keeps rotating keys, organization + # and deadlines request-local; only its HTTP transport is reused. + sdk = AsyncOpenAI( + api_key=args.get('api_key') or litellm.api_key or litellm.openai_key or get_secret('OPENAI_API_KEY'), + base_url=( + args.get('api_base') + or litellm.api_base + or get_secret('OPENAI_BASE_URL') + or get_secret('OPENAI_API_BASE') + or 'https://api.openai.com/v1' + ), + http_client=client, + organization=args.get('organization') or litellm.organization, + timeout=args.get('timeout', 120), + max_retries=0, + ) + yield {**args, 'client': sdk} + + def _cached_metadata(self, key, compute): + if not hasattr(self, '_metadata_cache'): + self._metadata_cache = OrderedDict() + cache = self._metadata_cache + cached = cache.get(key) + if cached is not None and time.monotonic() - cached[0] < 300: + cache.move_to_end(key) + return copy.deepcopy(cached[1]) + value = compute() + cache[key] = (time.monotonic(), copy.deepcopy(value)) + cache.move_to_end(key) + while len(cache) > 128: + cache.popitem(last=False) + return value 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.""" @@ -228,8 +319,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester): def _get_custom_llm_provider(self) -> str | None: return self.requester_cfg.get('custom_llm_provider') or None + def _uses_openrouter_protocol(self) -> bool: + # OpenRouter's shipped metadata also serves OpenAI-compatible non-chat + # operations. Select gateway chat semantics independently of it. + return ( + self._get_custom_llm_provider() == 'openrouter' + or self.requester_cfg.get('requester_name') == 'openrouter-chat-completions' + ) + 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.""" + return self._cached_metadata( + ('bool', helper_name, model_name), lambda: self._uncached_litellm_bool_helper(helper_name, model_name) + ) + + def _uncached_litellm_bool_helper(self, helper_name, model_name): helper = getattr(litellm, helper_name, None) if not callable(helper): return False @@ -328,6 +432,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): return None def _safe_context_length(self, model_name: str) -> int | None: + return self._cached_metadata(('model', model_name), lambda: self._uncached_model_info(model_name)) + + def _uncached_model_info(self, model_name): helper = getattr(litellm, 'get_model_info', None) if not callable(helper): return self._known_context_length_fallback(model_name) @@ -592,6 +699,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester): } if family == 'qwen' and 'disabled' in capabilities['levels'] and 'enabled' not in capabilities['levels']: capabilities['legacy_levels'] = ['enabled'] + if self._uses_openrouter_protocol(): + capabilities['budget_tokens'] = {'min': 1, 'max': 32768} + elif family == 'anthropic' and not self._is_anthropic_adaptive_model(normalized_name): + if normalized_name.rsplit('/', 1)[-1].startswith( + ('claude-3-7-', 'claude-sonnet-4', 'claude-opus-4', 'claude-haiku-4') + ): + capabilities['budget_tokens'] = {'min': 1024, 'max': 32768} + elif family == 'qwen' and self._supports_qwen_thinking_budget(model_name): + capabilities['budget_tokens'] = {'min': 1, 'max': 32768} + elif family == 'gemini' and 'gemini-2.5' in normalized_name: + capabilities['budget_tokens'] = { + 'min': 128 if 'pro' in normalized_name else (512 if 'flash-lite' in normalized_name else 1), + 'max': 32768 if 'pro' in normalized_name else 24576, + } return capabilities def _build_reasoning_args(self, model: requester.RuntimeLLMModel) -> dict[str, typing.Any]: @@ -599,14 +720,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester): if level == 'provider_default': return {} - config = {'level': level} + config = reasoning.normalize_reasoning_config(self._reasoning_config_value(model)) capabilities = self.get_reasoning_capabilities(model) try: reasoning.validate_reasoning_capabilities(config, capabilities, model.model_entity.name) except ValueError as exc: - raise errors.RequesterError(str(exc)) from exc + raise errors.RequesterError(str(exc), code='invalid_policy') from exc family = self._reasoning_family(model.model_entity.name, model) + budget = config.get('budget_tokens') + if budget is not None: + if self._uses_openrouter_protocol(): + return {'extra_body': {'reasoning': {'max_tokens': budget}}} + if family == 'qwen': + return {'extra_body': {'enable_thinking': True, 'thinking_budget': budget}} + return {'thinking': {'type': 'enabled', 'budget_tokens': budget}} if level == 'disabled': if family in {'deepseek', 'kimi', 'mimo', 'doubao'}: return {'extra_body': {'thinking': {'type': 'disabled'}}} @@ -708,6 +836,13 @@ class LiteLLMRequester(requester.ProviderAPIRequester): req_messages = [] for m in messages: msg_dict = m.dict(exclude_none=True) + provider_fields = msg_dict.get('provider_specific_fields') + if isinstance(provider_fields, dict): + # Stream completion metadata belongs to the response, never to + # replayed chat messages. Keep provider signatures for LiteLLM. + provider_fields.pop('finish_reason', None) + if not provider_fields: + msg_dict.pop('provider_specific_fields', None) content = msg_dict.get('content') if msg_dict.get('role') == 'assistant' and reasoning_family: @@ -780,6 +915,17 @@ class LiteLLMRequester(requester.ProviderAPIRequester): return req_messages + @staticmethod + def _adapt_messages_for_provider(messages: list[dict], chat_provider: str | None) -> None: + """Adapt serialized request messages in place for the chat transport.""" + if chat_provider == 'groq': + # Groq rejects LiteLLM's metadata envelope on messages and tool calls. + for message in messages: + message.pop('provider_specific_fields', None) + for tool_call in message.get('tool_calls') or []: + tool_call.pop('provider_specific_fields', None) + tool_call.get('function', {}).pop('provider_specific_fields', None) + _THINK_PATTERNS: tuple[str, ...] = ( r'^\s*(?:(?!).)*?\s*', r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*', @@ -1058,7 +1204,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 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'): + if self.requester_cfg.get('num_retries') is not None: args['num_retries'] = self.requester_cfg['num_retries'] if self.requester_cfg.get('api_version'): args['api_version'] = self.requester_cfg['api_version'] @@ -1066,24 +1212,102 @@ class LiteLLMRequester(requester.ProviderAPIRequester): 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)}') + if isinstance(e, errors.RequesterError): + raise e + metadata = error_metadata(e) + mappings = ( + (litellm.ContextWindowExceededError, 'context_length', t('Context length exceeded'), False), + (litellm.AuthenticationError, 'authentication', t('Invalid API key'), False), + (litellm.NotFoundError, 'not_found', t('Invalid model or endpoint'), False), + (litellm.RateLimitError, 'rate_limit', t('Rate limit exceeded or insufficient balance'), True), + (litellm.Timeout, 'timeout', t('Request timed out'), True), + (litellm.APIConnectionError, 'connection', t('Connection failed'), True), + (litellm.BadRequestError, 'bad_request', t('Invalid request parameters'), False), + ) + for exception_type, code, message, retryable in mappings: + if isinstance(e, exception_type): + body = getattr(e, 'body', None) + body = body if isinstance(body, dict) else {} + upstream_error = body.get('error', body) + upstream_code = upstream_error.get('code') if isinstance(upstream_error, dict) else None + if upstream_code in ('insufficient_quota', 'quota_exceeded', 'billing_hard_limit_reached'): + code, retryable = 'quota_exceeded', False + raise errors.RequesterError(message, code=code, retryable=retryable, **metadata) from e + status = metadata['status_code'] + raise errors.RequesterError( + t('API error') if isinstance(e, litellm.APIError) else t('Unknown error'), + code='upstream_error', + retryable=status in (408, 409, 429, 500, 502, 503, 504), + **metadata, + ) from e + + def _apply_transport(self, args, model, extra_args): + state = current_call.get() + policy = ( + state.policy + if state + else RequestPolicy.resolve(self.requester_cfg, model.model_entity.extra_args, extra_args) + ) + args.pop('request_policy', None) + if state: + # The Host owns retries; do not multiply attempts in nested SDKs. + args['num_retries'] = 0 + args['max_retries'] = 0 + args['timeout'] = httpx.Timeout( + connect=policy.connect_timeout, + read=policy.read_timeout, + write=policy.write_timeout, + pool=policy.pool_timeout, + ) + state.mark('request_prepared_ms') + base = args.get('api_base', '') + is_openrouter = urlsplit(base).hostname == 'openrouter.ai' or ( + not base and self._get_custom_llm_provider() == 'openrouter' + ) + headers = dict(args.get('extra_headers') or {}) + existing = {key.lower() for key in headers} + if policy.attribution and is_openrouter: + if 'http-referer' not in existing: + headers['HTTP-Referer'] = 'https://langbot.app' + if not {'x-title', 'x-openrouter-title'} & existing: + headers['X-OpenRouter-Title'] = 'LangBot' + conversation_header = policy.conversation_header or ('x-session-id' if is_openrouter else None) + if state and state.conversation_id and conversation_header and conversation_header not in existing: + headers[conversation_header] = state.conversation_id + if headers: + args['extra_headers'] = headers + + async def _completion(self, args): + state = current_call.get() + if state: + state.mark('request_started_ms') + result = await state.wait( + lambda: acompletion(**args), + 'first_event' if args.get('stream') else 'response', + state.policy.first_event_timeout if args.get('stream') else state.policy.total_timeout, + ) + state.mark('response_open_ms') + return result + return await acompletion(**args) + + async def _raw_chunks(self, response): + state = current_call.get() + iterator = response.__aiter__() + while True: + try: + if state: + chunk = await state.wait( + lambda: anext(iterator), + 'stream_idle' if state.raw_started else 'first_event', + state.policy.stream_idle_timeout if state.raw_started else state.policy.first_event_timeout, + ) + state.raw_started = True + state.mark('first_event_ms') + else: + chunk = await anext(iterator) + except StopAsyncIteration: + return + yield chunk async def _build_completion_args( self, @@ -1094,14 +1318,32 @@ class LiteLLMRequester(requester.ProviderAPIRequester): stream: bool = False, ) -> dict: """Build common completion arguments for invoke_llm and invoke_llm_stream.""" + preparation_start = time.perf_counter() + state = current_call.get() + cache_preparation = state is not None and state.policy.retries > 0 + scoped_model = copy.copy(model) + try: + scoped_model.reasoning_config_override = reasoning.resolve_reasoning_config(model, extra_args) + except ValueError as exc: + raise errors.RequesterError(str(exc), code='invalid_policy') from exc + model = scoped_model reasoning_family = self._reasoning_family(model.model_entity.name, model) reasoning_level = self._reasoning_level(model) - req_messages = self._convert_messages( - messages, - reasoning_family=reasoning_family, - include_reasoning_context=reasoning_level != 'disabled', - ) - model_name = self._build_litellm_model_name(model.model_entity.name) + message_key = ('messages', id(messages), reasoning_family, reasoning_level != 'disabled') + if cache_preparation and message_key in state.prepared: + req_messages = copy.deepcopy(state.prepared[message_key]) + state.metrics['preparation_cache_hits'] = state.metrics.get('preparation_cache_hits', 0) + 1 + else: + req_messages = self._convert_messages( + messages, reasoning_family=reasoning_family, include_reasoning_context=reasoning_level != 'disabled' + ) + if cache_preparation: + state.prepared[message_key] = copy.deepcopy(req_messages) + # OpenRouter shares requester metadata with OpenAI-compatible non-chat + # operations, but chat needs its own adapter to preserve cache markers. + chat_provider = 'openrouter' if self._uses_openrouter_protocol() else self._get_custom_llm_provider() + self._adapt_messages_for_provider(req_messages, chat_provider) + model_name = self._build_litellm_model_name(model.model_entity.name, chat_provider) api_key = model.provider.token_mgr.get_token() args = { @@ -1114,10 +1356,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester): args['stream_options'] = {'include_usage': True} self._build_common_args(args) + if chat_provider == 'openrouter': + args['custom_llm_provider'] = chat_provider + # 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) + args.pop('reasoning_config', None) reasoning_args = self._build_reasoning_args(model) if reasoning_args: @@ -1142,8 +1388,23 @@ class LiteLLMRequester(requester.ProviderAPIRequester): raise errors.RequesterError('allowed_openai_params must be an array') args['allowed_openai_params'] = list(dict.fromkeys([*allowed_openai_params, 'reasoning_effort'])) + budget = model.reasoning_config_override.get('budget_tokens') + if budget is not None and reasoning_family == 'anthropic' and not self._uses_openrouter_protocol(): + output_limit = args.get('max_completion_tokens', args.get('max_tokens')) + if type(output_limit) is not int or output_limit <= budget: + raise errors.RequesterError( + 'Anthropic budget_tokens requires explicit max_tokens greater than the budget', + code='invalid_policy', + ) + if funcs: - tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs) + tool_key = ('tools', id(funcs)) + if cache_preparation and tool_key in state.prepared: + tools = copy.deepcopy(state.prepared[tool_key]) + else: + tools = copy.deepcopy(await self.ap.tool_mgr.generate_tools_for_openai(funcs)) + if cache_preparation: + state.prepared[tool_key] = copy.deepcopy(tools) if tools: args['tools'] = tools args.setdefault('tool_choice', 'auto') @@ -1152,6 +1413,15 @@ class LiteLLMRequester(requester.ProviderAPIRequester): # and imports proxy-only dependencies such as FastAPI. args['_skip_mcp_handler'] = True + cache_family = reasoning_family + if cache_family == 'openrouter': + cache_family = self._infer_reasoning_family_from_model_name(model.model_entity.name) or cache_family + apply_prompt_caching(args, state, provider=self._get_custom_llm_provider(), family=cache_family) + self._apply_transport(args, model, extra_args) + if state: + state.metrics['preparation_ms'] = round( + state.metrics.get('preparation_ms', 0) + (time.perf_counter() - preparation_start) * 1000, 3 + ) return args async def count_tokens( @@ -1173,7 +1443,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester): count_args['tool_choice'] = args['tool_choice'] try: - tokens = litellm.token_counter(**count_args) + tokens = await asyncio.to_thread(litellm.token_counter, **count_args) except Exception as e: self._handle_litellm_error(e) @@ -1193,38 +1463,41 @@ class LiteLLMRequester(requester.ProviderAPIRequester): """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) + async with self._client_args(args) as args: + try: + response = await self._completion(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' + 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) - thinking_blocks = message_data.get('thinking_blocks') - if reasoning_content or thinking_blocks: - provider_fields = dict(message_data.get('provider_specific_fields') or {}) - if reasoning_content: - provider_fields['reasoning_content'] = reasoning_content - if thinking_blocks: - provider_fields['thinking_blocks'] = thinking_blocks - message_data['provider_specific_fields'] = provider_fields - display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None - message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think) + content = message_data.get('content', '') + reasoning_content = message_data.get('reasoning_content', None) + thinking_blocks = message_data.get('thinking_blocks') + if reasoning_content or thinking_blocks: + provider_fields = dict(message_data.get('provider_specific_fields') or {}) + if reasoning_content: + provider_fields['reasoning_content'] = reasoning_content + if thinking_blocks: + provider_fields['thinking_blocks'] = thinking_blocks + message_data['provider_specific_fields'] = provider_fields + display_reasoning = reasoning_content or self._thinking_blocks_text(thinking_blocks) or None + message_data['content'] = self._process_thinking_content(content, display_reasoning, remove_think) - if 'reasoning_content' in message_data: - del message_data['reasoning_content'] - if 'thinking_blocks' in message_data: - del message_data['thinking_blocks'] + if 'reasoning_content' in message_data: + del message_data['reasoning_content'] + if 'thinking_blocks' in message_data: + del message_data['thinking_blocks'] - message = provider_message.Message(**message_data) - usage_info = self._extract_usage(response) + message = provider_message.Message(**message_data) + usage_info = self._extract_usage(response) + if current_call.get(): + current_call.get().usage = usage_info - return message, usage_info + return message, usage_info - except Exception as e: - self._handle_litellm_error(e) + except Exception as e: + self._handle_litellm_error(e) async def invoke_llm_stream( self, @@ -1245,132 +1518,151 @@ class LiteLLMRequester(requester.ProviderAPIRequester): reasoning_started = False reasoning_closed = False thinking_blocks_state: list[dict[str, typing.Any]] = [] + response = None - 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 + async with self._client_args(args) as args: + try: + response = await self._completion(args) + async for chunk in self._raw_chunks(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): + if current_call.get(): + current_call.get().usage = self._extract_usage(chunk) + 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 + 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) + 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'] + if 'role' in delta and delta['role']: + role = delta['role'] - delta_content = delta.get('content') or '' - reasoning_content = delta.get('reasoning_content') or '' - provider_fields = dict(delta.get('provider_specific_fields') or {}) - if finish_reason: - # Preserve an explicit provider stop even when the final delta is empty. - provider_fields['finish_reason'] = finish_reason - raw_thinking_blocks = delta.get('thinking_blocks') - if raw_thinking_blocks: - thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks) - provider_fields['thinking_blocks'] = thinking_blocks_state - thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks) - display_reasoning_content = reasoning_content or thinking_blocks_text + state = current_call.get() + if state and delta.get('content'): + state.mark('first_content_ms') + if state and (delta.get('reasoning_content') or delta.get('thinking_blocks')): + state.mark('first_reasoning_ms') + delta_content = delta.get('content') or '' + reasoning_content = delta.get('reasoning_content') or '' + provider_fields = dict(delta.get('provider_specific_fields') or {}) + if finish_reason: + # Preserve an explicit provider stop even when the final delta is empty. + provider_fields['finish_reason'] = finish_reason + raw_thinking_blocks = delta.get('thinking_blocks') + if raw_thinking_blocks: + thinking_blocks_state = self._merge_thinking_blocks(thinking_blocks_state, raw_thinking_blocks) + provider_fields['thinking_blocks'] = thinking_blocks_state + thinking_blocks_text = self._thinking_blocks_text(raw_thinking_blocks) + display_reasoning_content = reasoning_content or thinking_blocks_text - # Handle reasoning_content based on remove_think flag - if reasoning_content: - provider_fields['reasoning_content'] = reasoning_content - if remove_think: - delta_content = delta_content or None - else: - # Stream explicit markers so downstream adapters and - # the debug page see the same format as non-streaming - # responses. - if not reasoning_started: - delta_content = '\n' - reasoning_started = True + # Handle reasoning_content based on remove_think flag + if reasoning_content: + provider_fields['reasoning_content'] = reasoning_content + if remove_think: + delta_content = delta_content or None else: - delta_content = '' - delta_content += display_reasoning_content - if delta.get('content'): - delta_content += f'\n\n{delta.get("content")}' - reasoning_closed = True + # Stream explicit markers so downstream adapters and + # the debug page see the same format as non-streaming + # responses. + if not reasoning_started: + delta_content = '\n' + reasoning_started = True + else: + delta_content = '' + delta_content += display_reasoning_content + if delta.get('content'): + delta_content += f'\n\n{delta.get("content")}' + reasoning_closed = True - elif display_reasoning_content: - if remove_think: - delta_content = delta_content or None - else: - if not reasoning_started: - delta_content = '\n' - reasoning_started = True + elif display_reasoning_content: + if remove_think: + delta_content = delta_content or None else: - delta_content = '' - delta_content += display_reasoning_content - if delta.get('content'): - delta_content += f'\n\n{delta.get("content")}' - reasoning_closed = True + if not reasoning_started: + delta_content = '\n' + reasoning_started = True + else: + delta_content = '' + delta_content += display_reasoning_content + if delta.get('content'): + delta_content += f'\n\n{delta.get("content")}' + reasoning_closed = True - elif delta_content and not remove_think and reasoning_started and not reasoning_closed: - delta_content = f'\n\n{delta_content}' - reasoning_closed = True + elif delta_content and not remove_think and reasoning_started and not reasoning_closed: + delta_content = f'\n\n{delta_content}' + reasoning_closed = True - if finish_reason and not remove_think and reasoning_started and not reasoning_closed: - delta_content = f'{delta_content}\n\n' - reasoning_closed = True + if finish_reason and not remove_think and reasoning_started and not reasoning_closed: + delta_content = f'{delta_content}\n\n' + reasoning_closed = True - if think_state is not None and delta_content: - delta_content = think_state.feed(delta_content) + if think_state is not None and delta_content: + delta_content = think_state.feed(delta_content) - tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state) + tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state) - if not delta_content and not tool_calls and not provider_fields and not finish_reason: + if not delta_content and not tool_calls and not provider_fields and not finish_reason: + 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 provider_fields: + chunk_data['provider_specific_fields'] = provider_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 - 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 provider_fields: - chunk_data['provider_specific_fields'] = provider_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 - - if reasoning_started and not reasoning_closed: - yield provider_message.MessageChunk( - role=role, - content='\n\n', - is_final=True, - ) - - if think_state is not None: - pending_content = think_state.flush() - if pending_content: + if reasoning_started and not reasoning_closed: yield provider_message.MessageChunk( role=role, - content=pending_content, + content='\n\n', is_final=True, ) - except Exception as e: - self._handle_litellm_error(e) + if think_state is not None: + pending_content = think_state.flush() + if pending_content: + yield provider_message.MessageChunk( + role=role, + content=pending_content, + is_final=True, + ) + + except Exception as e: + self._handle_litellm_error(e) + + finally: + # Closing a downstream async generator must release the provider's + # HTTP stream immediately, including cancellation while at yield. + close = getattr(response, 'aclose', None) + if callable(close): + try: + await asyncio.wait_for(close(), timeout=5.0) + except Exception as exc: + self.ap.logger.debug(f'Failed to close LLM stream: {type(exc).__name__}') async def invoke_embedding( self, @@ -1543,9 +1835,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester): body = await httpclient.response_text(e.response) except Exception: pass - raise errors.RequesterError(f'rerank 请求失败 (HTTP {e.response.status_code}): {body or str(e)}') + raise errors.RequesterError(t('API error') + f' (HTTP {e.response.status_code}): {body or str(e)}') except httpx.HTTPError as e: - raise errors.RequesterError(f'rerank 连接错误: {str(e)}') + raise errors.RequesterError(t('Connection failed') + f': {e}') raw_results = data.get('results', []) if isinstance(data, dict) else [] results = [] diff --git a/src/langbot/pkg/provider/session/sessionmgr.py b/src/langbot/pkg/provider/session/sessionmgr.py index 74b9a8c40..4e4f1076b 100644 --- a/src/langbot/pkg/provider/session/sessionmgr.py +++ b/src/langbot/pkg/provider/session/sessionmgr.py @@ -271,8 +271,22 @@ class SessionManager: async def get_session(self, query: pipeline_query.Query) -> provider_session.Session: """获取会话""" session_key, execution_context = _query_session_key(query) + from ...agent.runner.context_reset import get_reset_generation + + generation = None + persistence = getattr(self.ap, 'persistence_mgr', None) + if persistence is not None: + generation = await get_reset_generation( + persistence.get_db_engine(), + execution_context.workspace_uuid, + query.bot_uuid, + f'{str(query.launcher_type.value).lower()}_{query.launcher_id}', + ) now = time.monotonic() session = self._session_index.get(session_key) + if session is not None and getattr(session, '_context_generation', None) != generation: + self._remove_session(session) + session = None if session is not None: self._touch_session(session, session_key, now) return session @@ -319,6 +333,7 @@ class SessionManager: object.__setattr__(session, 'bot_uuid', query.bot_uuid) object.__setattr__(session, '_execution_context', session_context) object.__setattr__(session, '_langbot_session_key', session_key) + object.__setattr__(session, '_context_generation', generation) object.__setattr__(session, '_langbot_session_concurrency', session_concurrency) session._semaphore = asyncio.Semaphore(session_concurrency) self._session_index[session_key] = session diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 48501292b..3cb8b8cf5 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1,5 +1,7 @@ from __future__ import annotations +from ....i18n import t + import dataclasses import enum import json @@ -1464,9 +1466,6 @@ class RuntimeMCPSession: 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'), @@ -1493,7 +1492,7 @@ class RuntimeMCPSession: # Raw transport exceptions may echo command arguments, headers, or # environment values. Detailed diagnostics belong in AUDIT_VIEW # logs; resource-list responses expose only a stable status. - 'error_message': 'MCP runtime failed' if self.error_message else None, + 'error_message': t('MCP runtime failed') if self.error_message else None, 'error_code': self._public_error_code if self.error_message else None, 'error_phase': self.error_phase.value if self.error_phase else None, 'retry_count': self.retry_count, diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py index ae01cc632..258b849b4 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py @@ -18,12 +18,11 @@ 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, ) +from ....utils.python_workspace import normalize_host_path, wrap_python_command_with_env if TYPE_CHECKING: from .mcp import RuntimeMCPSession diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 201f4c4a1..9d03deb26 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -33,7 +33,7 @@ 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} +SANDBOX_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'} @@ -261,7 +261,11 @@ class NativeToolLoader(loader.ToolLoader): return list(self._tools) async def has_tool(self, name: str) -> bool: - return name in _ALL_TOOL_NAMES and await self._is_sandbox_available() + return name in SANDBOX_TOOL_NAMES and await self._is_sandbox_available() + + @staticmethod + def recognizes_tool(name: str) -> bool: + return name in SANDBOX_TOOL_NAMES async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query): from ....box.runner import binding_for @@ -309,7 +313,7 @@ class NativeToolLoader(loader.ToolLoader): include_visible=False, include_activated=True, ) - referenced_skill_names = skill_loader.find_referenced_skill_names(command) + referenced_skill_names = skill_loader.find_referenced_skill_names(command, query) if selected_skill is None and referenced_skill_names: if len(referenced_skill_names) > 1: @@ -329,41 +333,35 @@ class NativeToolLoader(loader.ToolLoader): if not package_root: raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.') - # Pass only the logical name across the authenticated Core→Runtime - # boundary. In Cloud mode the shared Box Runtime resolves the - # Workspace-scoped package root and constructs the read-only mount; - # Core host paths are never accepted as mount authority. # 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}' + # Dependency state stays writable and separate from the published package. + skill_mount = skill_loader.get_virtual_skill_mount_path(selected_skill_name, selected_skill['revision']) + legacy_mount = skill_loader.get_virtual_skill_mount_path(selected_skill_name) + command = regex.sub(regex.escape(legacy_mount) + r'(?![A-Za-z0-9_-])', skill_mount, command) + if workdir == legacy_mount or workdir.startswith(legacy_mount + '/'): + workdir = skill_mount + workdir[len(legacy_mount) :] + parameters = {**parameters, 'command': command, 'workdir': workdir} python_project = selected_skill.get('python_project') is True - if 'python_project' not in selected_skill and bool( - getattr(self.ap.box_service, 'shares_filesystem_with_box', False) - ): - # Backward compatibility for a same-process OSS Runtime that - # predates trusted Box metadata. Never probe a path reported by - # an external Runtime from the Core filesystem. + if 'python_project' not in selected_skill: python_project = skill_loader.should_prepare_skill_python_env(package_root) if python_project: + revision_key = str(selected_skill.get('revision', '') or '').removeprefix('sha256:') + if not revision_key: + raise ValueError(f'Activated skill "{selected_skill_name}" has no pinned revision.') parameters = dict(parameters) parameters['command'] = skill_loader.wrap_skill_command_with_python_env( command, mount_path=skill_mount, - state_path=f'/workspace/.skill-envs/{selected_skill_name}', + state_path=f'/workspace/.skill-envs/{selected_skill_name}/{revision_key}', ) - # 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. + # Package paths contain the pinned digest, so no shared alias changes underneath another run. result = await self.ap.box_service.execute_tool( parameters, query, - skill_name=selected_skill_name, + read_only_mounts=skill_loader.build_execution_mounts(self.ap, query), ) result = self._normalize_exec_result(result) - - if selected_skill is not None: - self._refresh_skill_from_disk(query, selected_skill) return result def _resolve_host_location( @@ -389,10 +387,7 @@ class NativeToolLoader(loader.ToolLoader): box_service = self.ap.box_service if selected_skill is not None: if not self._can_interpret_skill_host_paths(): - raise ValueError( - 'Skill package paths are owned by the Box Runtime; ' - 'this operation requires a Runtime skill-file API.' - ) + raise ValueError('Secure Core host file operations are unavailable on this platform.') host_root = selected_skill.get('package_root') workspace_anchor = None else: @@ -430,11 +425,9 @@ class NativeToolLoader(loader.ToolLoader): return selected_skill, relative def _can_interpret_skill_host_paths(self) -> bool: - """Require an explicitly proven shared Core/Runtime filesystem view.""" + """Return whether Core can use its no-follow host file primitives.""" - return _SECURE_HOST_FILE_OPS_AVAILABLE and bool( - getattr(self.ap.box_service, 'shares_filesystem_with_box', False) - ) + return _SECURE_HOST_FILE_OPS_AVAILABLE def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool: if selected_skill is not None: @@ -1136,34 +1129,27 @@ else: skill_request = self._resolve_skill_relative_path( query, path, - include_visible=True, + include_visible=False, include_activated=True, ) - if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'): + skill_repository = getattr(self.ap, 'skill_repository', None) + if skill_request is not None and skill_repository is not None: selected_skill, relative = skill_request - if self._can_interpret_skill_host_paths(): - host_location = self._resolve_skill_host_location(selected_skill, relative) - else: - host_location = None - if host_location is not None: - try: - return await asyncio.to_thread(self._read_host_location, host_location, parameters) - except FileNotFoundError: - pass - try: - result = await self.ap.box_service.read_skill_file( + result = await skill_repository.read_skill_resource( self._execution_context(query), selected_skill['name'], relative, + expected_revision=selected_skill.get('revision'), ) return self._build_read_result_from_text(str(result.get('content', '')), parameters) except Exception: try: - result = await self.ap.box_service.list_skill_files( + result = await skill_repository.list_skill_resources( self._execution_context(query), selected_skill['name'], relative, + expected_revision=selected_skill.get('revision'), ) entries = [entry['name'] for entry in result.get('entries', [])] return self._build_directory_result(entries) @@ -1173,7 +1159,7 @@ else: host_location = self._resolve_host_location( query, path, - include_visible=True, + include_visible=False, include_activated=True, ) if host_location is None: @@ -1187,21 +1173,22 @@ else: 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) + 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 - execution_context = self._execution_context(query) - await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content) - await self.ap.skill_mgr.reload_skills(execution_context) - return {'ok': True, 'path': path} + if skill_request is not None: + return { + 'ok': False, + 'error': ( + 'Published Skill revisions are immutable. Copy the package to a writable ' + 'directory under /workspace/skill-drafts, edit it there, then call register_skill ' + 'with the activated revision as base_revision.' + ), + } host_location = self._resolve_host_location( query, @@ -1232,36 +1219,15 @@ else: 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( - self._execution_context(query), - 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) - execution_context = self._execution_context(query) - await self.ap.box_service.write_skill_file( - execution_context, - selected_skill['name'], - relative, - new_content, - ) - await self.ap.skill_mgr.reload_skills(execution_context) - return {'ok': True, 'path': path} + if skill_request is not None: + return { + 'ok': False, + 'error': ( + 'Published Skill revisions are immutable. Copy the package to a writable ' + 'directory under /workspace/skill-drafts, edit it there, then call register_skill ' + 'with the activated revision as base_revision.' + ), + } host_location = self._resolve_host_location( query, @@ -1561,7 +1527,7 @@ else: host_location = self._resolve_host_location( query, path, - include_visible=True, + include_visible=False, include_activated=True, ) if host_location is None: @@ -1583,7 +1549,7 @@ else: host_location = self._resolve_host_location( query, path, - include_visible=True, + include_visible=False, include_activated=True, ) if host_location is None: diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py index b6a97ceaf..233268891 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill.py +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -1,10 +1,16 @@ from __future__ import annotations +import os import re import typing -from ....box import workspace as box_workspace +from langbot_plugin.box.models import ARTIFACT_MOUNT_ROOT + from ....api.http.context import ExecutionContext +from ....utils.python_workspace import ( + should_prepare_python_env, + wrap_python_command_with_env, +) if typing.TYPE_CHECKING: from ....core import app @@ -17,7 +23,9 @@ 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: +def get_virtual_skill_mount_path(skill_name: str, revision: str | None = None) -> str: + if revision is not None: + return f'{ARTIFACT_MOUNT_ROOT}/{revision.removeprefix("sha256:")}' return f'{SKILL_MOUNT_PREFIX}/{skill_name}' @@ -70,6 +78,37 @@ def register_created_skill_visibility(query: pipeline_query.Query, skill_name: s bound_skills.append(skill_name) +def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]: + """Mount only immutable revisions pinned by this run's activations.""" + + mounts: list[dict] = [] + for skill_name, skill_data in get_activated_skills(query).items(): + package_root = str(skill_data.get('package_root', '') or '').strip() + manifest_path = str(skill_data.get('manifest_path', '') or '').strip() + revision = str(skill_data.get('revision', '') or '').strip() + if not package_root: + raise ValueError(f'Activated skill "{skill_name}" has no immutable package root.') + if not revision: + raise ValueError(f'Activated skill "{skill_name}" has no pinned revision.') + if not os.path.isdir(package_root): + raise ValueError( + f'Activated skill "{skill_name}" pinned revision {revision} is unavailable; ' + 'the run cannot be recovered safely.' + ) + if not manifest_path or not os.path.isfile(manifest_path): + raise ValueError(f'Activated skill "{skill_name}" pinned revision {revision} has no publication manifest.') + mounts.append( + { + 'host_path': package_root, + 'mount_path': get_virtual_skill_mount_path(skill_name, revision), + 'mode': 'ro', + 'content_digest': revision, + 'manifest_path': manifest_path, + } + ) + return mounts + + def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]: if query.variables is None: return {} @@ -84,14 +123,18 @@ def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict | return get_activated_skills(query).get(skill_name) -def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None: +def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> dict: 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 + revision = str(skill_data.get('revision', '') or '').strip() + if not skill_name or not revision: + raise ValueError('Activated Skills require a name and immutable revision.') + if skill_name not in activated: + activated[skill_name] = dict(skill_data) + return activated[skill_name] def normalize_skill_names(value: typing.Any) -> list[str]: @@ -112,89 +155,37 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]: 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 names from the current visible skill set.""" - 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 +async def get_persisted_skill_revision(ap: app.Application, query: pipeline_query.Query, name: str) -> str | None: + """Read a pin only for the currently authorized Host run.""" + session = getattr(query, '_agent_run_session', None) + if not isinstance(session, dict): + return None + from ....agent.runner.run_ledger_store import RunLedgerStore, TERMINAL_STATUSES - -def restore_activated_skills_from_state( - ap: app.Application, - query: pipeline_query.Query, - state: dict[str, dict[str, typing.Any]], -) -> list[str]: - """Restore persisted activated skill names into Query variables. - - The state value stores names only. Full skill metadata is rebuilt from the - current pipeline-visible skill cache so removed or unbound skills remain - unavailable to native exec/write/edit. - """ - conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {} - skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY)) - return restore_activated_skills(ap, query, skill_names) + context = getattr(query, '_execution_context', None) + workspace_id = str(getattr(context, 'workspace_uuid', None) or getattr(query, 'workspace_uuid', '') or '') + run = await RunLedgerStore(ap.persistence_mgr.get_db_engine()).get_run(str(session['run_id'])) + if run is None or run['workspace_id'] != workspace_id or run['status'] in TERMINAL_STATUSES: + raise ValueError('Skill recovery requires an active run in the current Workspace') + return (run.get('metadata') or {}).get('activated_skills', {}).get(name) async def persist_activated_skill( ap: app.Application, query: pipeline_query.Query, skill_name: str, -) -> None: - """Persist activated skill names into host-owned conversation state. - - ``activate`` runs host-side. This writes the run's current activated skill - names to the conversation-scope ``host.activated_skills`` snapshot so a later - run can restore them via ``restore_activated_skills_from_state``. Host writes - here and a runner ``state.updated`` to the same key follow last-write-wins. - - Best-effort: a persistence failure must not fail the activation itself. No-op - when the call is not inside an authorized agent run, or when conversation - state is unavailable (state disabled / scope not enabled / no conversation). - """ + revision: str, +) -> str: + """Persist one pin in the run ledger, never in Runner-writable conversation state.""" session = getattr(query, '_agent_run_session', None) if not isinstance(session, dict): - return + return revision + from ....agent.runner.run_ledger_store import RunLedgerStore - authorization = session.get('authorization') - if not isinstance(authorization, dict): - return - - state_context = authorization.get('state_context') - if not isinstance(state_context, dict): - return - - scope_keys = state_context.get('scope_keys') - conversation_scope_key = scope_keys.get('conversation') if isinstance(scope_keys, dict) else None - if not conversation_scope_key: - return - - try: - from ....agent.runner.persistent_state_store import get_persistent_state_store - - store = get_persistent_state_store(ap.persistence_mgr.get_db_engine()) - await store.state_set( - scope_key=conversation_scope_key, - state_key=ACTIVATED_SKILL_NAMES_STATE_KEY, - value=get_activated_skill_names(query), - runner_id=str(session.get('runner_id', '') or ''), - binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'), - scope='conversation', - context=state_context, - logger=getattr(ap, 'logger', None), - ) - except Exception as e: # noqa: BLE001 - persistence is best-effort, must not break activation - logger = getattr(ap, 'logger', None) - if logger is not None: - logger.warning(f'Failed to persist activated skill "{skill_name}": {e}') + context = getattr(query, '_execution_context', None) + workspace_id = str(getattr(context, 'workspace_uuid', None) or getattr(query, 'workspace_uuid', '') or '') + store = RunLedgerStore(ap.persistence_mgr.get_db_engine()) + return await store.pin_skill_revision(str(session['run_id']), workspace_id, skill_name, revision) def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]: @@ -224,6 +215,12 @@ def resolve_virtual_skill_path( include_visible: bool, include_activated: bool, ) -> tuple[dict | None, str]: + if sandbox_path == ARTIFACT_MOUNT_ROOT or sandbox_path.startswith(ARTIFACT_MOUNT_ROOT + '/'): + for skill in get_activated_skills(query).values(): + root = get_virtual_skill_mount_path(skill['name'], skill['revision']) + if sandbox_path == root or sandbox_path.startswith(root + '/'): + return skill, '/workspace' + sandbox_path[len(root) :] + raise ValueError('Artifact path does not belong to a Skill activated by this run') skill_name, rewritten_path = parse_skill_mount_path(sandbox_path) if skill_name is None: return None, rewritten_path @@ -246,7 +243,7 @@ def resolve_virtual_skill_path( ) -def find_referenced_skill_names(text: str) -> list[str]: +def find_referenced_skill_names(text: str, query=None) -> list[str]: if not text: return [] @@ -254,6 +251,20 @@ def find_referenced_skill_names(text: str) -> list[str]: for match in _SKILL_MOUNT_PATTERN.findall(text): if match not in seen: seen.append(match) + if query is not None: + for path in re.findall(re.escape(ARTIFACT_MOUNT_ROOT) + r'/[a-f0-9]{64}', text): + selected = next( + ( + name + for name, skill in get_activated_skills(query).items() + if get_virtual_skill_mount_path(name, skill['revision']) == path + ), + None, + ) + if selected is None: + raise ValueError('Artifact command references a revision not activated by this run') + if selected not in seen: + seen.append(selected) return seen @@ -275,7 +286,7 @@ def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str def should_prepare_skill_python_env(package_root: str | None) -> bool: - return box_workspace.should_prepare_python_env(package_root) + return should_prepare_python_env(package_root) def wrap_skill_command_with_python_env( @@ -284,7 +295,7 @@ def wrap_skill_command_with_python_env( mount_path: str = '/workspace', state_path: str | None = None, ) -> str: - return box_workspace.wrap_python_command_with_env( + return wrap_python_command_with_env( command, mount_path=mount_path, state_path=state_path, diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 92e6ea0a0..3e8e0060f 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -6,7 +6,6 @@ import typing import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from .. import loader -from .availability import is_box_backend_available from ....api.http.context import ExecutionContext # Align with Claude Code's Skill tool design: @@ -15,12 +14,23 @@ from ....api.http.context import ExecutionContext # - This protects KV Cache and follows industry standard ACTIVATE_SKILL_TOOL_NAME = 'activate' +LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources' +READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource' REGISTER_SKILL_TOOL_NAME = 'register_skill' -SKILL_TOOL_NAMES = { +READ_ONLY_SKILL_TOOL_NAMES = { ACTIVATE_SKILL_TOOL_NAME, + LIST_SKILL_RESOURCES_TOOL_NAME, + READ_SKILL_RESOURCE_TOOL_NAME, +} +SANDBOX_SKILL_TOOL_NAMES = { REGISTER_SKILL_TOOL_NAME, } +SKILL_TOOL_NAMES = READ_ONLY_SKILL_TOOL_NAMES | SANDBOX_SKILL_TOOL_NAMES +_SKILL_EXECUTION_AVAILABLE_KEY = '_skill_execution_available' +_SKILL_RESOURCE_BYTES_READ_KEY = '_skill_resource_bytes_read' +_MAX_SKILL_RESOURCE_FILE_BYTES = 256 * 1024 +_MAX_SKILL_RESOURCE_RUN_BYTES = 1024 * 1024 class SkillToolLoader(loader.ToolLoader): @@ -28,77 +38,77 @@ class SkillToolLoader(loader.ToolLoader): def __init__(self, ap): super().__init__(ap) - self._tools: list[resource_tool.LLMTool] = [] - self._sandbox_available: bool = False + self._read_only_tools: list[resource_tool.LLMTool] = [] + self._sandbox_tools: list[resource_tool.LLMTool] = [] 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_skill_tools() + if self._is_available(): + self._read_only_tools = [ + self._build_activate_skill_tool(), + self._build_list_skill_resources_tool(), + self._build_read_skill_resource_tool(), + ] + self._sandbox_tools = [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. ' - 'Trusted local development may explicitly select box.backend=host.' - ) + self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.') - 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 await self._is_available(): + async def get_tools( + self, + bound_plugins: list[str] | None = None, + *, + sandbox_available: bool | None = None, + ) -> list[resource_tool.LLMTool]: + if not self._is_available(): return [] - if not self._tools: - self._tools = self._build_skill_tools() - return list(self._tools) - - def _build_skill_tools(self) -> list[resource_tool.LLMTool]: - tools = [self._build_activate_skill_tool()] - if self._skill_registration_available(): - tools.append(self._build_register_skill_tool()) - else: - self.ap.logger.info( - 'register_skill is NOT available: Cloud sandboxes never scan arbitrary ' - 'host skill directories. Install skills through the skill store instead.' - ) + if not self._read_only_tools: + await self.initialize() + tools = list(self._read_only_tools) + if sandbox_available and self._skill_registration_available(): + tools.extend(self._sandbox_tools) return tools + async def get_tool(self, name: str, *, sandbox_available: bool | None = None): + for tool in await self.get_tools(sandbox_available=sandbox_available): + if tool.name == name: + return tool + return None + def _skill_registration_available(self) -> bool: """Host skill scanning is disabled for Cloud-managed sandboxes.""" + return not bool(getattr(getattr(self.ap, 'box_service', None), 'managed_admission_required', False)) - box_service = getattr(self.ap, 'box_service', None) - return not bool(getattr(box_service, 'managed_admission_required', False)) - - async def has_tool(self, name: str) -> bool: - if not await self._is_available() or name not in SKILL_TOOL_NAMES: + async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool: + if not self._is_available() or name not in SKILL_TOOL_NAMES: return False - if name == REGISTER_SKILL_TOOL_NAME: - return self._skill_registration_available() - return True + return name in READ_ONLY_SKILL_TOOL_NAMES or (bool(sandbox_available) and self._skill_registration_available()) - async def _is_available(self) -> bool: - """Check if skill tools should be available. + @staticmethod + def is_sandbox_tool(name: str) -> bool: + return name in SANDBOX_SKILL_TOOL_NAMES - Skill tools require both a skill manager and a sandbox backend. - """ - if not self._has_skill_manager(): - return False - self._sandbox_available = await self._check_sandbox_available() - return self._sandbox_available + @staticmethod + def recognizes_tool(name: str) -> bool: + return name in SKILL_TOOL_NAMES + + def _is_available(self) -> bool: + return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any: - require_sandbox = getattr( - getattr(self.ap, 'box_service', None), - 'require_workspace_sandbox', - None, - ) - if callable(require_sandbox): - await require_sandbox(self._execution_context(query)) if name == ACTIVATE_SKILL_TOOL_NAME: return await self._invoke_activate_skill(parameters, query) + if name == LIST_SKILL_RESOURCES_TOOL_NAME: + return await self._invoke_list_skill_resources(parameters, query) + if name == READ_SKILL_RESOURCE_TOOL_NAME: + return await self._invoke_read_skill_resource(parameters, query) if name == REGISTER_SKILL_TOOL_NAME: + require_sandbox = getattr( + getattr(self.ap, 'box_service', None), + 'require_workspace_sandbox', + None, + ) + if not callable(require_sandbox): + return self._sandbox_unavailable_result(name) + await require_sandbox(self._execution_context(query)) return await self._invoke_register_skill(parameters, query) raise ValueError(f'Unknown skill tool: {name}') @@ -123,6 +133,27 @@ class SkillToolLoader(loader.ToolLoader): def _has_skill_manager(self) -> bool: return getattr(self.ap, 'skill_mgr', None) is not None + @staticmethod + def _sandbox_unavailable_result(name: str) -> dict: + return { + 'ok': False, + 'code': 'sandbox_unavailable', + 'tool': name, + 'message': 'This operation requires Box execution, but Box is not configured or available.', + } + + async def _execution_available(self, query) -> bool: + variables = getattr(query, 'variables', None) + if isinstance(variables, dict) and _SKILL_EXECUTION_AVAILABLE_KEY in variables: + return bool(variables[_SKILL_EXECUTION_AVAILABLE_KEY]) + checker = getattr(getattr(self.ap, 'box_service', None), 'is_workspace_sandbox_available', None) + if not callable(checker): + return False + try: + return bool(await checker(self._execution_context(query))) + except Exception: + return False + 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() @@ -131,43 +162,117 @@ class SkillToolLoader(loader.ToolLoader): from . import skill as skill_loader - skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name) + bound_names = skill_loader.get_bound_skill_names(query) + if bound_names is not None and skill_name not in bound_names: + raise ValueError(f'Skill "{skill_name}" is not authorized for this run') + skill_data = skill_loader.get_activated_skill(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}') + pinned_revision = await skill_loader.get_persisted_skill_revision(self.ap, query, skill_name) + if pinned_revision is None and skill_loader.get_visible_skill(self.ap, query, skill_name) is None: + raise ValueError(f'Skill "{skill_name}" not found in the authorized catalog') + skill_data = await self.ap.skill_repository.get_skill( + self._execution_context(query), skill_name, snapshot=True, revision=pinned_revision + ) + if skill_data is None: + raise ValueError(f'Skill "{skill_name}" revision is unavailable') - # Register activated skill for sandbox mount path resolution - skill_loader.register_activated_skill(query, skill_data) - await skill_loader.persist_activated_skill(self.ap, query, skill_name) + # Persist before exposing the activation; a failed write must not produce an unrecorded pin. + pinned_revision = await skill_loader.persist_activated_skill(self.ap, query, skill_name, skill_data['revision']) + if pinned_revision != skill_data['revision']: + skill_data = await self.ap.skill_repository.get_skill( + self._execution_context(query), skill_name, snapshot=True, revision=pinned_revision + ) + if skill_data is None: + raise ValueError('The run-pinned Skill revision is unavailable') + skill_data = 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) + revision = str(skill_data.get('revision', '') or '') + execution_available = await self._execution_available(query) + mount_path = skill_loader.get_virtual_skill_mount_path(skill_name, revision) if execution_available else None - # 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'{revision}\n' + result_content += 'true\n' + result_content += f'{str(execution_available).lower()}\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 += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n' + if execution_available: + result_content += f'- Package files will be prepared read-only at {mount_path} before execution.\n' + else: + result_content += ( + '- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n' + ) result_content += '\n' return { 'activated': True, 'skill_name': skill_name, 'mount_path': mount_path, + 'revision': revision, + 'capabilities': { + 'instructions_readable': True, + 'resources_readable': True, + 'execution_available': execution_available, + }, 'activated_skill_names': skill_loader.get_activated_skill_names(query), 'content': result_content, } + @staticmethod + def _activated_skill(parameters: dict, query) -> dict: + from . import skill as skill_loader + + skill_name = str(parameters.get('skill_name', '') or '').strip() + if not skill_name: + raise ValueError('skill_name is required') + skill_data = skill_loader.get_activated_skill(query, skill_name) + if skill_data is None: + raise ValueError(f'Skill "{skill_name}" must be activated before its resources can be read.') + requested_revision = str(parameters.get('revision', '') or '').strip() + activated_revision = str(skill_data.get('revision', '') or '').strip() + if requested_revision and requested_revision != activated_revision: + raise ValueError('revision must match the activated skill revision') + return skill_data + + async def _invoke_list_skill_resources(self, parameters: dict, query) -> dict: + skill_data = self._activated_skill(parameters, query) + return await self.ap.skill_repository.list_skill_resources( + self._execution_context(query), + skill_data['name'], + str(parameters.get('path', '.') or '.'), + expected_revision=skill_data.get('revision'), + ) + + async def _invoke_read_skill_resource(self, parameters: dict, query) -> dict: + skill_data = self._activated_skill(parameters, query) + path = str(parameters.get('path', '') or '').strip() + if not path: + raise ValueError('path is required') + result = await self.ap.skill_repository.read_skill_resource( + self._execution_context(query), + skill_data['name'], + path, + expected_revision=skill_data.get('revision'), + ) + content = str(result.get('content', '')) + size = len(content.encode('utf-8')) + if size > _MAX_SKILL_RESOURCE_FILE_BYTES: + raise ValueError('Skill resource exceeds the per-file read limit') + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + variables = {} + query.variables = variables + total = int(variables.get(_SKILL_RESOURCE_BYTES_READ_KEY, 0) or 0) + size + if total > _MAX_SKILL_RESOURCE_RUN_BYTES: + raise ValueError('Skill resource reads exceed the per-run limit') + variables[_SKILL_RESOURCE_BYTES_READ_KEY] = total + result['size'] = size + return result + async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any: """Register a skill from sandbox directory to data/skills/.""" from . import skill as skill_loader @@ -193,16 +298,17 @@ class SkillToolLoader(loader.ToolLoader): if not skill_name: raise ValueError('skill name is required') - # Create the skill - created = await skill_service.create_skill( + base_revision = str(parameters.get('base_revision', '') or '').strip() or None + published = await skill_service.import_skill_directory( execution_context, + host_path, { '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, }, + base_revision=base_revision, ) skill_loader.register_created_skill_visibility(query, skill_name) @@ -210,7 +316,8 @@ class SkillToolLoader(loader.ToolLoader): 'registered': True, 'skill_name': skill_name, 'source_path': sandbox_path, - 'skill': created, + 'revision': published.get('revision'), + 'skill': published, } def _resolve_workspace_directory( @@ -268,14 +375,51 @@ class SkillToolLoader(loader.ToolLoader): func=lambda parameters: parameters, ) + def _build_list_skill_resources_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=LIST_SKILL_RESOURCES_TOOL_NAME, + human_desc='List activated skill resources', + description='List read-only files in an activated skill package without starting a sandbox.', + parameters={ + 'type': 'object', + 'properties': { + 'skill_name': {'type': 'string', 'description': 'The activated skill name.'}, + 'path': {'type': 'string', 'description': 'Relative directory path. Defaults to the package root.'}, + 'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'}, + }, + 'required': ['skill_name'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_read_skill_resource_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=READ_SKILL_RESOURCE_TOOL_NAME, + human_desc='Read an activated skill resource', + description='Read a UTF-8 text resource from an activated skill package without starting a sandbox.', + parameters={ + 'type': 'object', + 'properties': { + 'skill_name': {'type': 'string', 'description': 'The activated skill name.'}, + 'path': {'type': 'string', 'description': 'File path relative to the skill package root.'}, + 'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'}, + }, + 'required': ['skill_name', 'path'], + '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. ' + "Publish a skill draft from a directory under /workspace into LangBot's skill store. " + 'Use this after creating or preparing the draft with exec/read/write/edit. ' 'The directory must contain a SKILL.md file. ' + 'Updating an existing Skill requires its current base_revision; conflicting updates are rejected. ' 'After registration, the skill can be activated with the activate tool.' ), parameters={ @@ -301,6 +445,10 @@ class SkillToolLoader(loader.ToolLoader): 'type': 'string', 'description': 'Optional instructions override.', }, + 'base_revision': { + 'type': 'string', + 'description': 'Required current revision when publishing an update; omit for a new Skill.', + }, }, 'required': ['path'], 'additionalProperties': False, diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index b09dff9d8..b4116a9e0 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -2,6 +2,7 @@ from __future__ import annotations import typing import time +import asyncio import inspect from typing import TYPE_CHECKING @@ -76,6 +77,15 @@ class ToolManager: except Exception: return False + @staticmethod + def _sandbox_unavailable_result(name: str) -> dict[str, typing.Any]: + return { + 'ok': False, + 'code': 'sandbox_unavailable', + 'tool': name, + 'message': 'This operation requires Box execution, but Box is not configured or available.', + } + async def initialize(self): from langbot.pkg.utils import importutil from langbot.pkg.provider.tools import loaders @@ -112,8 +122,8 @@ class ToolManager: sandbox_available = await self._workspace_sandbox_available(context) if sandbox_available: all_functions.extend(await self.native_tool_loader.get_tools()) - if include_skill_authoring and sandbox_available: - all_functions.extend(await self.skill_tool_loader.get_tools()) + if include_skill_authoring: + all_functions.extend(await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available)) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) all_functions.extend( await self.mcp_tool_loader.get_tools( @@ -152,8 +162,12 @@ class ToolManager: sandbox_available = await self._workspace_sandbox_available(context) if sandbox_available: append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools()) - if include_skill_authoring and sandbox_available: - append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools()) + if include_skill_authoring: + append_tools( + 'skill', + 'LangBot', + await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available), + ) catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins)) if self.mcp_tool_loader: @@ -295,10 +309,9 @@ class ToolManager: tool = await active_loader.get_tool(name) if tool: return tool - if sandbox_available: - tool = await self.skill_tool_loader.get_tool(name) - if tool: - return tool + tool = await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available) + if tool: + return tool return await self.mcp_tool_loader.get_tool(context, name) @@ -362,7 +375,8 @@ class ToolManager: if source in {'builtin', 'native'}: return await self.native_tool_loader.get_tool(name) if source == 'skill': - return await self.skill_tool_loader.get_tool(name) + sandbox_available = await self._workspace_sandbox_available(context) + return await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available) if source == 'plugin': if not source_id: return None @@ -448,6 +462,19 @@ class ToolManager: start_time = time.perf_counter() try: result = await invoke() + except asyncio.CancelledError: + 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='cancelled', + ) + self._trace_tool_call(source=source, name=name, query=query, outcome='cancelled', error='cancelled') + raise except Exception as e: duration_ms = int((time.perf_counter() - start_time) * 1000) await self._record_tool_call( @@ -459,6 +486,14 @@ class ToolManager: status='error', error_message=str(e), ) + outcome = 'timeout' if isinstance(e, TimeoutError) else 'failed' + self._trace_tool_call( + source=source, + name=name, + query=query, + outcome=outcome, + error=str(e) or type(e).__name__, + ) raise duration_ms = int((time.perf_counter() - start_time) * 1000) @@ -471,8 +506,60 @@ class ToolManager: status='success', result=result, ) + self._trace_tool_call(source=source, name=name, query=query, outcome='success') return result + def _trace_tool_call( + self, + *, + source: str, + name: str, + query: pipeline_query.Query, + outcome: str, + error: str = '', + ) -> None: + """Report one tool invocation as its own step of the execution chain. + + Every tool call a Run makes - sandbox, skill, plugin or MCP - is the + observable action of that Run, so the chain shows it under the step that + issued it instead of collapsing the Run into a single opaque node. + + An Agent's tool call is executed for the Runtime's RPC task, which never + inherited the chain context. The Run id the caller already carries is the + alias that task's record resolves the owning execution through. + """ + # Imported here: telemetry pulls the application, and the tool manager is + # constructed while the application is still assembling. + from ...telemetry.execution import record as record_execution + from ...telemetry.platform import processing_mode + + try: + record_execution( + self.ap, + get_query_execution_context(query), + execution_id=self._trace_execution_id(query), + family='tool', + operation=name, + adapter=source, + mode=processing_mode.get(), + outcome=outcome, + error=error, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to record tool call trace: {e}') + + @staticmethod + def _trace_execution_id(query: pipeline_query.Query) -> str: + """The execution chain this call belongs to, when the caller knows it. + + ``''`` lets the emitter fall back to the task's own trace context, which + is what a Pipeline-lane tool call has. + """ + session = getattr(query, '_agent_run_session', None) + if not isinstance(session, dict): + return '' + return str(session.get('run_id') or '').strip() + async def execute_func_call( self, name: str, @@ -480,8 +567,6 @@ class ToolManager: query: pipeline_query.Query, source_ref: ToolSourceRef | None = None, ) -> typing.Any: - from langbot.pkg.telemetry import features as telemetry_features - source_ref = source_ref or self.get_query_tool_source(query, name) if source_ref is not None: execution_context = get_query_execution_context(query) @@ -497,11 +582,11 @@ class ToolManager: telemetry_source = 'native' exists = await loader.has_tool(name) elif source == 'skill': - if not sandbox_available: + if not sandbox_available and self.skill_tool_loader.is_sandbox_tool(name): raise ToolNotFoundError(name) loader = self.skill_tool_loader telemetry_source = 'skill' - exists = await loader.has_tool(name) + exists = await loader.has_tool(name, sandbox_available=sandbox_available) elif source == 'plugin' and source_id: loader = self.plugin_tool_loader telemetry_source = 'plugin' @@ -522,6 +607,9 @@ class ToolManager: if not exists: raise ToolNotFoundError(name) + if source == 'skill' and isinstance(getattr(query, 'variables', None), dict): + query.variables['_skill_execution_available'] = sandbox_available + async def invoke_selected_tool() -> typing.Any: if source == 'mcp': return await loader.invoke_tool( @@ -534,7 +622,6 @@ class ToolManager: return await loader.invoke_tool(name, parameters, query, source_id=source_id) return await loader.invoke_tool(name, parameters, query) - telemetry_features.increment(query, 'tool_calls', telemetry_source) return await self._invoke_tool_with_monitoring( source=telemetry_source, name=name, @@ -547,7 +634,6 @@ class ToolManager: await self._bind_plugin_workspace(execution_context) sandbox_available = await self._workspace_sandbox_available(execution_context) if sandbox_available and 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, @@ -556,7 +642,6 @@ class ToolManager: invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query), ) if await self.plugin_tool_loader.has_tool(name): - telemetry_features.increment(query, 'tool_calls', 'plugin') return await self._invoke_tool_with_monitoring( source='plugin', name=name, @@ -565,7 +650,6 @@ class ToolManager: invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query), ) if await self.mcp_tool_loader.has_tool(execution_context, name): - telemetry_features.increment(query, 'tool_calls', 'mcp') return await self._invoke_tool_with_monitoring( source='mcp', name=name, @@ -573,8 +657,10 @@ class ToolManager: query=query, invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query), ) - if sandbox_available and await self.skill_tool_loader.has_tool(name): - telemetry_features.increment(query, 'tool_calls', 'skill') + if await self.skill_tool_loader.has_tool(name, sandbox_available=sandbox_available): + variables = getattr(query, 'variables', None) + if isinstance(variables, dict): + variables['_skill_execution_available'] = sandbox_available return await self._invoke_tool_with_monitoring( source='skill', name=name, @@ -582,6 +668,13 @@ class ToolManager: query=query, invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query), ) + recognizes_native = getattr(self.native_tool_loader, 'recognizes_tool', None) + is_sandbox_skill_tool = getattr(self.skill_tool_loader, 'is_sandbox_tool', None) + if not sandbox_available and ( + (callable(recognizes_native) and recognizes_native(name) is True) + or (callable(is_sandbox_skill_tool) and is_sandbox_skill_tool(name) is True) + ): + return self._sandbox_unavailable_result(name) raise ToolNotFoundError(name) async def shutdown(self): diff --git a/src/langbot/pkg/skill/__init__.py b/src/langbot/pkg/skill/__init__.py index b96f23ca1..96e673f3a 100644 --- a/src/langbot/pkg/skill/__init__.py +++ b/src/langbot/pkg/skill/__init__.py @@ -1,3 +1,5 @@ from .manager import SkillManager +from .repository import SkillRepository -__all__ = ['SkillManager'] + +__all__ = ['SkillManager', 'SkillRepository'] diff --git a/src/langbot/pkg/skill/activation.py b/src/langbot/pkg/skill/activation.py deleted file mode 100644 index 27ce1b3da..000000000 --- a/src/langbot/pkg/skill/activation.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -import typing - -from ..provider.tools.loaders import skill as skill_loader -from ..api.http.context import ExecutionContext - -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 - - execution_context = ExecutionContext( - instance_uuid=str(getattr(query, 'instance_uuid', '') or ''), - workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''), - placement_generation=getattr(query, 'placement_generation', 0) or 0, - bot_uuid=getattr(query, 'bot_uuid', None), - pipeline_uuid=getattr(query, 'pipeline_uuid', None), - query_uuid=getattr(query, 'query_uuid', None), - ) - skill_data = skill_mgr.get_skill_by_name(execution_context, 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 index aeb688b7f..6b8f4e03e 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -1,14 +1,12 @@ from __future__ import annotations -import os - from ..api.http.context import ExecutionContext from ..api.http.service.tenant import TenantContext, require_workspace_uuid from ..core import app class SkillManager: - """Workspace-scoped in-memory view of Box-managed skill packages.""" + """Workspace-scoped in-memory view of Core-managed skill packages.""" ap: app.Application @@ -61,37 +59,23 @@ class SkillManager: for existing_key in tuple(self._skills_by_scope): if existing_key[:2] == key[:2] and existing_key != key: self._skills_by_scope.pop(existing_key, None) - self._skills_by_scope[key] = {} - box_service = getattr(self.ap, 'box_service', None) - if box_service is None or not getattr(box_service, 'available', False): - self.ap.logger.info( - f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.' - ) - return + repository = getattr(self.ap, 'skill_repository', None) + if repository is None: + raise RuntimeError('Skill repository unavailable') - validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False)) try: - dropped = 0 skills: dict[str, dict] = {} - for skill_data in await box_service.list_skills(execution_context): + for skill_data in await repository.list_skills(execution_context): 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 package_root ' - f'missing on LangBot filesystem ({package_root}); dropping from cache.' - ) - dropped += 1 - continue skills[skill_name] = skill_data self._skills_by_scope[key] = skills - suffix = f' ({dropped} dropped due to missing package_root)' if dropped else '' - self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}') + self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}') except Exception as exc: self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}') + raise async def ensure_loaded(self, context: TenantContext) -> None: key = self._scope_key(context) diff --git a/src/langbot/pkg/skill/repository.py b/src/langbot/pkg/skill/repository.py new file mode 100644 index 000000000..0d1de38dc --- /dev/null +++ b/src/langbot/pkg/skill/repository.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import asyncio +import os +import weakref + +from langbot_plugin.skill_store import ( + SkillRevisionConflictError, + SkillRevisionMismatchError, + SkillRevisionNotFoundError, + SkillStore, + skill_namespace, +) + +from ..api.http.context import ExecutionContext +from ..api.http.service.tenant import TenantContext, require_workspace_uuid +from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic + + +class SkillRepository: + """Async, Workspace-scoped adapter around the SDK SkillStore.""" + + def __init__(self, ap) -> None: + self.ap = ap + config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {} + self._local_config = (config.get('box') or {}).get('local') or {} + self._skills_config = config.get('skills') or {} + self._store = SkillStore(self._skills_root()) + self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() + + def _workspace_lock(self, namespace: str) -> asyncio.Lock: + lock = self._locks.get(namespace) + if lock is None: + lock = asyncio.Lock() + self._locks[namespace] = lock + return lock + + def _host_root(self) -> str: + configured = str(self._local_config.get('host_root') or './data/box').strip() + return os.path.realpath(os.path.abspath(os.path.expanduser(configured))) + + def _skills_root(self) -> str: + configured = str(self._skills_config.get('root') or '').strip() + if not configured: + # Online-upgrade bridge for installations whose persisted config + # predates the standalone Skill domain. + # TODO(next-major): remove box.local.skills_root fallback. + legacy = str(self._local_config.get('skills_root') or '').strip() + configured = legacy or 'skills' + if not os.path.isabs(configured): + configured = os.path.join(self._host_root(), configured) + return os.path.realpath(os.path.abspath(os.path.expanduser(configured))) + + def _default_workspace(self) -> str: + configured = str(self._local_config.get('default_workspace') or '').strip() + if not configured: + configured = os.path.join(self._host_root(), 'default') + elif not os.path.isabs(configured): + configured = os.path.join(self._host_root(), configured) + return os.path.realpath(os.path.abspath(os.path.expanduser(configured))) + + @staticmethod + def _execution_context(context: TenantContext) -> ExecutionContext: + workspace_uuid = require_workspace_uuid(context) + instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip() + generation = getattr(context, 'placement_generation', None) + if not instance_uuid: + raise ValueError('Skill operations require an explicit instance UUID') + if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0: + raise ValueError('Skill operations require a positive placement generation') + return ExecutionContext( + instance_uuid=instance_uuid, + workspace_uuid=workspace_uuid, + placement_generation=generation, + bot_uuid=getattr(context, 'bot_uuid', None), + pipeline_uuid=getattr(context, 'pipeline_uuid', None), + query_uuid=getattr(context, 'query_uuid', None), + entitlement_revision=getattr(context, 'entitlement_revision', 0), + ) + + @classmethod + def _namespace(cls, context: TenantContext) -> str: + execution_context = cls._execution_context(context) + return skill_namespace( + execution_context.instance_uuid, + execution_context.workspace_uuid, + ) + + async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext: + execution_context = self._execution_context(context) + binding = await self.ap.workspace_service.get_execution_binding( + execution_context.workspace_uuid, + expected_generation=execution_context.placement_generation, + ) + if ( + binding.instance_uuid != execution_context.instance_uuid + or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid + or getattr(binding, 'placement_generation', None) != execution_context.placement_generation + ): + raise ValueError('Skill execution context belongs to a stale Workspace placement') + return execution_context + + def _workspace_root(self, namespace: str) -> str: + return os.path.join(self._default_workspace(), 'tenants', namespace) + + def storage_root(self, context: TenantContext) -> str: + return self._store.scoped(self._namespace(context)).root + + async def _call(self, context: TenantContext, method_name: str, *args, **kwargs): + execution_context = await self._validated_execution_context(context) + namespace = self._namespace(execution_context) + + def invoke(): + method = getattr(self._store.scoped(namespace), method_name) + return method(*args, **kwargs) + + async with self._workspace_lock(namespace): + with blocking_work_scope(f'skill:{namespace}'): + return await run_blocking_atomic(invoke) + + async def list_skills(self, context: TenantContext) -> list[dict]: + return await self._call(context, 'list_skills') + + async def get_skill( + self, + context: TenantContext, + name: str, + *, + snapshot: bool = False, + revision: str | None = None, + ) -> dict | None: + if revision is not None: + return await self._call(context, 'get_skill_snapshot', name, revision) + return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name) + + async def create_skill(self, context: TenantContext, skill: dict) -> dict: + return await self._call(context, 'create_skill', skill) + + async def import_skill_directory( + self, + context: TenantContext, + path: str, + skill: dict, + *, + base_revision: str | None = None, + ) -> dict: + namespace = self._namespace(context) + return await self._call( + context, + 'import_skill_directory', + path, + skill, + source_root=self._workspace_root(namespace), + base_revision=base_revision, + ) + + async def update_skill( + self, + context: TenantContext, + name: str, + skill: dict, + *, + base_revision: str | None, + ) -> dict: + return await self._call( + context, + 'update_skill', + name, + skill, + base_revision=base_revision, + ) + + async def delete_skill(self, context: TenantContext, name: str) -> None: + await self._call(context, 'delete_skill', name) + + async def scan_skill_directory(self, context: TenantContext, path: str) -> dict: + namespace = self._namespace(context) + return await self._call( + context, + 'scan_import_directory', + path, + source_root=self._workspace_root(namespace), + ) + + async def list_skill_files( + self, + context: TenantContext, + name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries) + + async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict: + return await self._call(context, 'read_skill_file', name, path) + + async def list_skill_resources( + self, + context: TenantContext, + name: str, + path: str = '.', + *, + expected_revision: str | None = None, + ) -> dict: + return await self._call( + context, + 'list_skill_resources', + name, + path, + False, + 200, + expected_revision=expected_revision, + ) + + async def read_skill_resource( + self, + context: TenantContext, + name: str, + path: str, + *, + expected_revision: str | None = None, + ) -> dict: + return await self._call( + context, + 'read_skill_resource', + name, + path, + expected_revision=expected_revision, + ) + + async def write_skill_file( + self, + context: TenantContext, + name: str, + path: str, + content: str, + *, + base_revision: str | None, + ) -> dict: + return await self._call( + context, + 'write_skill_file', + name, + path, + content, + base_revision=base_revision, + ) + + async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]: + return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs) + + async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]: + return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs) + + +__all__ = [ + 'SkillRepository', + 'SkillRevisionConflictError', + 'SkillRevisionMismatchError', + 'SkillRevisionNotFoundError', +] diff --git a/src/langbot/pkg/storage/inline_media_cleanup.py b/src/langbot/pkg/storage/inline_media_cleanup.py new file mode 100644 index 000000000..3293fcca4 --- /dev/null +++ b/src/langbot/pkg/storage/inline_media_cleanup.py @@ -0,0 +1,147 @@ +"""Incrementally expire historical inline images without rewriting record history.""" + +from __future__ import annotations + +import asyncio +import datetime +import json +from typing import TYPE_CHECKING + +import sqlalchemy as sa + +from ..api.http.authz import WorkspaceRequiredError +from ..api.http.context import ExecutionContext +from ..entity.persistence.agent_run import AgentRun, AgentRunEvent +from ..entity.persistence.event_log import EventLog +from ..entity.persistence.metadata import WorkspaceMetadata +from ..entity.persistence.monitoring import MonitoringMessage +from ..entity.persistence.transcript import Transcript +from ..persistence.datetime_utils import as_naive_utc +from .media import MediaCache + +if TYPE_CHECKING: + from ..core.app import Application + + +ROWS_PER_TABLE = 100 +CHARACTERS_PER_TABLE = 8 * 1024 * 1024 +CURSOR_PREFIX = 'inline_media_ttl_v1:' + + +def _redact_columns(values: dict) -> dict: + updates = {} + for name, raw in values.items(): + if not raw or ('base64' not in raw and 'data:image/' not in raw): + continue + try: + payload = json.loads(raw) + except (ValueError, TypeError, RecursionError): + # Preserve malformed historical text rather than deleting an entire record. + continue + if MediaCache.has_inline_images(payload): + updates[name] = json.dumps(MediaCache.redact_inline_images(payload), ensure_ascii=False) + return updates + + +async def cleanup_inline_media(ap: Application, context: ExecutionContext, retention_days: int) -> int: + """Use the media TTL for immutable history, with durable per-Workspace cursors. + + Bound each table by both candidate count and serialized payload characters. One + oversized row is processed on its own so it cannot permanently block the cursor. + """ + if not isinstance(context, ExecutionContext) or not context.workspace_uuid: + raise WorkspaceRequiredError('Inline media cleanup requires an ExecutionContext') + if retention_days < 1: + raise ValueError('retention_days must be >= 1') + workspace = context.workspace_uuid + cutoff = as_naive_utc(datetime.datetime.now(datetime.timezone.utc)) - datetime.timedelta(days=retention_days) + sources = ( + ( + MonitoringMessage, + MonitoringMessage.timestamp, + MonitoringMessage.workspace_uuid == workspace, + ('message_content', 'variables'), + ), + (EventLog, EventLog.created_at, EventLog.workspace_id == workspace, ('input_json', 'metadata_json')), + (AgentRun, AgentRun.created_at, AgentRun.workspace_id == workspace, ('metadata_json',)), + ( + AgentRunEvent, + AgentRunEvent.created_at, + sa.exists( + sa.select(AgentRun.id).where( + AgentRun.run_id == AgentRunEvent.run_id, AgentRun.workspace_id == workspace + ) + ), + ('data_json', 'metadata_json'), + ), + ( + Transcript, + Transcript.created_at, + Transcript.workspace_id == workspace, + ('content_json', 'attachment_refs_json', 'metadata_json'), + ), + ) + manager = ap.persistence_mgr + cleaned = 0 + for model, timestamp, scope, fields in sources: + consumed = 0 + cursor_key = CURSOR_PREFIX + model.__tablename__ + cursor_filter = (WorkspaceMetadata.workspace_uuid == workspace, WorkspaceMetadata.key == cursor_key) + for _ in range(ROWS_PER_TABLE): + async with manager.tenant_uow(workspace): + cursor_result = await manager.execute_async( + sa.select(WorkspaceMetadata.value).where(*cursor_filter).with_for_update() + ) + cursor_raw = cursor_result.scalar_one_or_none() + # Events have no Workspace column. Limit indexed candidates before + # checking ownership so other Workspaces cannot cause unbounded scans. + query = sa.select(model.id, timestamp).where(timestamp < cutoff) + if model is not AgentRunEvent: + query = query.where(scope) + if cursor_raw is not None: + after_time, after_id = json.loads(cursor_raw) + query = query.where( + sa.tuple_(timestamp, model.id) + > sa.tuple_(datetime.datetime.fromisoformat(after_time), after_id) + ) + result = await manager.execute_async(query.order_by(timestamp, model.id).limit(1)) + row = result.first() + if row is None: + break + payload = ( + await manager.execute_async( + sa.select(*(getattr(model, field) for field in fields)).where(model.id == row[0], scope) + ) + ).first() + # Missing and foreign candidates still consume budget and advance the + # cursor. Their payloads are never returned to the cleanup worker. + values = dict(zip(fields, payload)) if payload is not None else {} + consumed += sum(len(value) for value in values.values() if value) + updates = await asyncio.to_thread(_redact_columns, values) if values else {} + if updates: + if model is AgentRun: + # Media expiry must not appear as new execution activity. + updates['updated_at'] = AgentRun.updated_at + changed = await manager.execute_async( + sa.update(model) + .where( + scope, + model.id == row[0], + *(getattr(model, name) == values[name] for name in fields if name in updates), + ) + .values(**updates) + ) + if not changed.rowcount: + # A concurrent writer changed this payload; retry it next time. + break + cleaned += 1 + cursor = json.dumps([row[1].isoformat(), row[0]]) + if cursor_raw is None: + await manager.execute_async( + sa.insert(WorkspaceMetadata).values(workspace_uuid=workspace, key=cursor_key, value=cursor) + ) + else: + await manager.execute_async(sa.update(WorkspaceMetadata).where(*cursor_filter).values(value=cursor)) + if consumed >= CHARACTERS_PER_TABLE: + break + return cleaned diff --git a/src/langbot/pkg/storage/media.py b/src/langbot/pkg/storage/media.py index 8f5537fb3..4a615abc1 100644 --- a/src/langbot/pkg/storage/media.py +++ b/src/langbot/pkg/storage/media.py @@ -11,6 +11,8 @@ import xxhash from pathlib import Path from typing import TYPE_CHECKING, Any +from ..utils.image import DEFAULT_BASE64_MEDIA_LIMIT + if TYPE_CHECKING: from ...core import app from . import mgr as storage_mgr @@ -49,14 +51,80 @@ class MediaCache: if split_index != -1: mime_type = data_url[5:split_index] b64_data = data_url[split_index + 8 :] - return base64.b64decode(b64_data), mime_type - # Try raw base64 if sufficiently long - if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')): - return base64.b64decode(data_url), 'application/octet-stream' + return MediaCache._decode_base64(b64_data), mime_type + if not data_url.startswith(('http://', 'https://', '/')): + return MediaCache._decode_base64(data_url), 'application/octet-stream' except Exception: return None return None + @staticmethod + def _decode_base64(value: str) -> bytes: + """Bound decoding before allocating a binary payload for persistence.""" + if len(value) > 4 * ((DEFAULT_BASE64_MEDIA_LIMIT + 2) // 3): + raise ValueError('Inline image exceeds the media size limit') + decoded = base64.b64decode(value, validate=True) + if len(decoded) > DEFAULT_BASE64_MEDIA_LIMIT: + raise ValueError('Inline image exceeds the media size limit') + return decoded + + @staticmethod + def _inline_image_payload(node: dict) -> str | None: + """Recognize platform and provider image payloads without fetching URLs.""" + node_type = node.get('type') + if node_type == 'Image': + payload = node.get('base64') + url = node.get('url') + elif node_type in ('image', 'image_url', 'image_base64'): + payload = node.get('image_base64') + url = node.get('image_url') + if isinstance(url, dict): + url = url.get('url') + else: + return None + if isinstance(payload, str) and payload: + return payload + if isinstance(url, str) and url.startswith('data:image/'): + return url + return None + + @classmethod + def has_inline_images(cls, value: Any) -> bool: + if isinstance(value, list): + return any(cls.has_inline_images(item) for item in value) + if isinstance(value, dict): + return cls._inline_image_payload(value) is not None or any( + cls.has_inline_images(item) for item in value.values() if isinstance(item, (list, dict)) + ) + return False + + @staticmethod + def _clear_inline_image(node: dict) -> None: + field = 'base64' if node.get('type') == 'Image' else 'image_base64' + if node.get(field): + node[field] = None + url_field = 'url' if node.get('type') == 'Image' else 'image_url' + url = node.get(url_field) + if isinstance(url, str) and url.startswith('data:image/'): + node[url_field] = None + elif isinstance(url, dict) and str(url.get('url', '')).startswith('data:image/'): + node[url_field] = {**url, 'url': None} + + @classmethod + def redact_inline_images(cls, value: Any) -> Any: + """Keep metadata-only records binary-free, including short raw base64.""" + if isinstance(value, list): + return [cls.redact_inline_images(item) for item in value] + if isinstance(value, dict): + node = dict(value) + if cls._inline_image_payload(node) is not None: + cls._clear_inline_image(node) + return { + key: cls.redact_inline_images(item) if isinstance(item, (list, dict)) else item + for key, item in node.items() + } + return value + @staticmethod def guess_extension(mime_type: str | None, default: str = '.jpg') -> str: """Guess appropriate file extension from MIME type.""" @@ -201,29 +269,33 @@ class MediaCache: return [await self.externalize_chain_dump(item) for item in chain_dump] if isinstance(chain_dump, dict): node = copy.copy(chain_dump) - node_type = node.get('type') - if node_type == 'Image': - b64 = node.get('base64') - if b64 and isinstance(b64, str): - try: - parsed = self.parse_data_url(b64) - if parsed is not None: - raw_bytes, mime_type = parsed - hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type) - filename = os.path.basename(storage_key) - current_url = node.get('url') or '' - if current_url and not current_url.startswith('data:'): - node['original_url'] = current_url - node['url'] = f'/api/v1/files/media/{filename}' - node['base64'] = None - node['hash'] = hash_str - node['storage_key'] = storage_key - node['size'] = size - node['mime_type'] = mime_type - except Exception as e: - if hasattr(self.ap, 'logger') and self.ap.logger: - self.ap.logger.warning(f'Failed to externalize image to media cache: {e}') - node['base64'] = None + payload = self._inline_image_payload(node) + if payload is not None: + try: + parsed = await asyncio.to_thread(self.parse_data_url, payload) + if parsed is None: + raise ValueError('Invalid inline image payload') + raw_bytes, mime_type = parsed + hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type) + url = f'/api/v1/files/media/{os.path.basename(storage_key)}' + if node.get('type') == 'Image': + current_url = node.get('url') or '' + if current_url and not current_url.startswith('data:'): + node['original_url'] = current_url + node['url'] = url + else: + current_url = node.get('image_url') + if isinstance(current_url, dict): + current_url = current_url.get('url') + if current_url and not current_url.startswith('data:'): + node['original_url'] = current_url + image_url = node.get('image_url') + node['image_url'] = {**image_url, 'url': url} if isinstance(image_url, dict) else {'url': url} + node.update(hash=hash_str, storage_key=storage_key, size=size, mime_type=mime_type) + except Exception as e: + if hasattr(self.ap, 'logger') and self.ap.logger: + self.ap.logger.warning(f'Failed to externalize image to media cache: {e}') + self._clear_inline_image(node) for k, v in list(node.items()): if isinstance(v, (list, dict)): node[k] = await self.externalize_chain_dump(v) diff --git a/src/langbot/pkg/telemetry/execution.py b/src/langbot/pkg/telemetry/execution.py index da0111f47..80123d4b7 100644 --- a/src/langbot/pkg/telemetry/execution.py +++ b/src/langbot/pkg/telemetry/execution.py @@ -1,18 +1,21 @@ -"""Content-free execution records for the existing telemetry sender. +"""Point-wise execution records for the existing telemetry sender. -This module reports observations only. Coverage catalogs and acceptance rules -belong to Space. +One execution is reported as a chain of independent records that all carry the +same ``event_id``: one ``execution_node`` per workflow node, emitted the moment +that node completes, plus exactly one ``execution_chain`` emitted when the +execution closes. The sender never assembles a trace locally, so there is no +per-trace buffer, no TTL and no per-process trace cap; memory is bounded by the +outbound record buffer, the flush batch size and the small cross-task registry. -One complete execution is one record: the execution identity (Pipeline query, -inbound platform event or Agent run), its bounded ordered stages and its -terminal outcome. Nothing is aggregated or counted across executions — every -record carries the id of the execution it belongs to, so Space can fetch the -whole chain by primary identity. Records are buffered whole and flushed as -``{"records": [...]}`` on the configured cadence and on shutdown; an in-flight -trace is never flushed half-written. +Platform events are reportable only after a business route, matching plugin +subscription or validated interaction callback admits the execution. Pending +events enqueue neither nodes nor closures, even on failure or in Cloud mode. +Independent API/WebUI executions remain eligible without a Bot route. -Memory is bounded in stages per trace, buffered traces, buffered records, the -cross-task execution registry and flush batch size. +Sampling is decided exactly once per event, at ingress, from a stable hash of +the execution identity, so every record of one execution carries the same +decision. A chain that broke is always emitted even when sampling excluded it, +which is why a chain may arrive with zero or partial nodes. """ from __future__ import annotations @@ -21,29 +24,48 @@ import asyncio import contextlib import contextvars import json +import re import time +import zlib from datetime import datetime, timezone from . import trace as trace_mod from .identity import workspace_identity +from .resources import snapshot as resources_snapshot +from .resources import begin as resources_begin +from .resources import end as resources_end from .trace import TraceState MODES = frozenset({'pipeline', 'agent', 'event_processor', 'none'}) OUTCOMES = frozenset({'success', 'failed', 'cancelled', 'timeout', 'skipped', 'unknown'}) +FAMILIES = frozenset({'platform_event', 'event_route', 'pipeline', 'runner', 'platform_api', 'tool'}) +ORIGINS = frozenset({'platform', 'webui', 'api'}) +CLOSED_BY = frozenset({'event_done', 'pipeline_done', 'runner_done', 'timeout'}) +CHAIN_FAILURES = frozenset({'failed', 'cancelled', 'timeout'}) -# Trace bounds: stages per trace, traces buffered per process, trace lifetime. -MAX_TRACES = 64 -TRACE_TTL_SECONDS = 120 +SCHEMA = 2 TRACE_MODES = frozenset({'off', 'failures', 'sampled', 'all'}) -DEFAULT_TRACE_MODE = 'sampled' +DEFAULT_TRACE_MODE = 'all' DEFAULT_TRACE_SAMPLE = 20 -# Record bounds: buffered records, one flush batch, flush cadence, registry. +# Sampling bounds: outbound record buffer, one flush batch, flush cadence. MAX_BUFFERED_RECORDS = 512 MAX_RECORDS_PER_FLUSH = 128 MAX_FLUSH_BYTES = 200 * 1024 DEFAULT_FLUSH_SECONDS = 180 +MAX_FLUSH_ATTEMPTS = 3 +FLUSH_RETRY_BACKOFF_SECONDS = 0.2 + +# Cross-task registry: execution id -> owning execution, bounded and time-limited. MAX_REGISTRY_KEYS = 256 +REGISTRY_TTL_SECONDS = 300 + +# Failure details are operator-facing: keep the failure class and a short +# machine-ish prefix, never the free text a user or a model produced. +ERROR_LIMIT = 200 +ERROR_PREFIX_CHARS = 120 +ERROR_PREFIX_TOKENS = 12 +_ERROR_CLASS = re.compile(r'[A-Za-z_][\w.]*') # Set by cross-task call sites (plugin/RPC actions) that run without the # ingress context but know which execution they belong to. @@ -68,16 +90,36 @@ def current_execution_id() -> str: return _execution_id.get() -def _bounded_error(value, limit: int = 400) -> str: - """Bound and de-fang one failure detail before it leaves the process. +def _short_prefix(text: str) -> str: + """Keep at most a short machine-ish prefix of one failure message.""" + if len(text) > ERROR_PREFIX_CHARS: + text = text[:ERROR_PREFIX_CHARS] + parts = text.split(' ') + if len(parts) > ERROR_PREFIX_TOKENS: + text = ' '.join(parts[:ERROR_PREFIX_TOKENS]) + return text - Error text is operator-facing: keep it single-line, printable and short so a - noisy failure cannot smuggle control characters or bloat the payload. + +def _bounded_error(value, limit: int = ERROR_LIMIT) -> str: + """Bound and redact one failure detail before it leaves the process. + + The failure class and a short machine-ish prefix survive; control characters + are stripped, whitespace is collapsed and the result is capped, so a noisy + failure cannot smuggle user or model content into the payload. """ if value is None: return '' text = ''.join(ch if ch.isprintable() else ' ' for ch in str(value)) text = ' '.join(text.split()) + if not text: + return '' + head, separator, rest = text.partition(':') + head = head.strip() + if separator and len(head) <= 80 and _ERROR_CLASS.fullmatch(head): + prefix = _short_prefix(rest.strip()) + text = f'{head}: {prefix}' if prefix else head + else: + text = _short_prefix(text) return text[:limit] @@ -91,18 +133,21 @@ def _duration_ms(started_at: str, ended_at: str) -> int: class ExecutionCounters: + """Point-wise execution reporting: one record per node, one per closure.""" + def __init__(self, manager): self.manager = manager self.records: list[dict] = [] self.task: asyncio.Task | None = None + # Exposed health: records dropped or accepted by the sender. self.dropped = 0 - self.traces: dict[str, TraceState] = {} - self.trace_deadlines: dict[str, float] = {} - # Execution identity -> in-flight trace, for stages recorded by tasks + self.sent = 0 + # Execution identity -> owning execution, for nodes recorded by tasks # that no longer share the ingress context (plugin/RPC platform calls). + # It resolves identity and the sampling decision only; node payloads are + # never accumulated here. self.registry: dict[str, TraceState] = {} self.registry_deadlines: dict[str, float] = {} - self.dropped_traces = 0 # ------------------------------------------------------------------ config @@ -130,6 +175,63 @@ class ExecutionCounters: seconds = DEFAULT_FLUSH_SECONDS return seconds if 1 <= seconds <= 86400 else DEFAULT_FLUSH_SECONDS + def health(self) -> dict: + """Real telemetry health for the instance liveness snapshot.""" + return { + 'records_buffered': len(self.records), + 'records_sent': self.sent, + 'records_dropped': self.dropped, + 'executions_in_flight': len({id(state) for state in self.registry.values()}), + } + + # ----------------------------------------------------------------- sampling + + def _sampling_decision(self, event_id: str) -> tuple[str, int, bool]: + """Decide once, deterministically, whether this event is reported. + + The identity is hashed instead of being parsed, so every id shape + (``platform:...``, uuids, debug ids) samples the same way. + """ + mode = self.trace_mode() + if mode == 'sampled': + denominator = self.trace_sample() + try: + sampled_in = zlib.crc32(str(event_id).encode('utf-8')) % denominator == 0 + except Exception: + sampled_in = False + return mode, denominator, sampled_in + # 'off' reports nothing, 'failures' keeps nodes out but still reports a + # broken chain, 'all' reports everything; denominator is 1 for all three. + return mode, 1, mode == 'all' + + def configure( + self, + state: TraceState, + context=None, + *, + debug: bool = False, + origin: str = 'platform', + identity=None, + ) -> None: + """Bind the execution identity, the sampling decision and the flags.""" + if state.configured: + return + state.configured = True + state.debug = bool(debug) + state.origin = origin if origin in ORIGINS else 'platform' + mode, denominator, sampled_in = self._sampling_decision(state.event_id) + state.mode = mode + state.denominator = denominator + # Debug chains are always emitted (unless reporting is off entirely). + state.emit_nodes = mode != 'off' and (state.debug or sampled_in) + if identity is not None: + state.identity = dict(identity) + elif context is not None: + try: + state.identity = workspace_identity(context) + except Exception: + pass + # --------------------------------------------------------------- recording def record( @@ -148,102 +250,96 @@ class ExecutionCounters: node: str = '', ): try: - cfg = self.manager.telemetry_config - if not cfg or cfg.get('disable_telemetry', False) or not cfg.get('url'): + if not self._enabled() or trace_mod.reporting_suppressed(): return - if family not in {'platform_event', 'event_route', 'pipeline', 'runner', 'platform_api'}: - return - if mode not in MODES or outcome not in OUTCOMES: + if family not in FAMILIES or mode not in MODES or outcome not in OUTCOMES: return # Only code-defined identifiers are accepted; never pass user values. - if any(not isinstance(v, str) or len(v) > 160 for v in (operation, adapter, runner, node)): + if any(not isinstance(v, str) or len(v.encode('utf-8')) > 512 for v in (operation, adapter, runner, node)): return state = trace_mod.current() if state is None: - # Cross-task work has no inherited trace; resolve the owning - # execution through the registry instead. + # Cross-task work has no inherited context; resolve the owning + # execution through the registry. It owns no step here, so the + # record attaches to the innermost step the chain has open - + # the step that caused this work. lookup = str(execution_id or '').strip() or current_execution_id() if lookup: - state = self.registry.get(lookup) - if state is None: + state = self._resolve(lookup) + if state is None or state.closed or not state.reportable: # Every observation belongs to exactly one execution chain. return + self.configure(state) try: identity = workspace_identity(context) except Exception: # A cross-task caller may only carry the Workspace id; reuse the - # identity already bound to the owning trace. + # identity already bound to the owning execution. identity = state.identity or {} if not identity.get('instance_id') or not identity.get('workspace_uuid'): return - self._record_trace_stage( - state, - identity=identity, - family=family, - operation=operation, - mode=mode, - adapter=adapter, - runner=runner, - outcome=outcome, - synthetic=synthetic, - error=_bounded_error(error), - node=node, + state.identity = identity + + ended_at = datetime.now(timezone.utc).isoformat() + node_id, seq, started_at, parent = trace_mod.resolve_node(state, str(node or ''), ended_at) + root = parent == '' and not state.root_seen + if root: + state.root_seen = True + bounded_error = _bounded_error(error) + state.note_node(outcome=outcome, adapter=adapter, runner=runner, family=family, error=bounded_error) + if not state.emit_nodes: + # Sampling excluded this execution: the node is never buffered, + # only counted so the terminal chain can explain the gap. + state.dropped_nodes += 1 + return + self._enqueue_record( + { + 'event_type': 'execution_node', + 'schema': SCHEMA, + **self._common_fields(state, ended_at), + 'node_id': node_id, + 'parent_node_id': parent, + 'root': root, + 'seq': seq, + 'family': family, + 'operation': operation, + 'mode': mode, + 'adapter': adapter, + 'runner': runner, + 'outcome': outcome, + 'error': bounded_error, + 'route_ref': trace_mod.current_route(), + 'run_id': trace_mod.current_run(), + 'synthetic': bool(synthetic), + 'started_at': started_at, + 'ended_at': ended_at, + } ) - self._ensure_loop() except Exception: # Observability must never change execution behavior. return - def _record_trace_stage( - self, - state: TraceState, - *, - identity, - family, - operation, - mode, - adapter, - runner, - outcome, - synthetic, - error: str = '', - node: str = '', - ) -> None: - if state.abandoned: - return - if trace_mod.current() is state: - # Recorded by the task that owns the step: its step stack is exact. - # A step's own record sits under its enclosing step, never itself. - parent = trace_mod.current_parent() - if node and parent == node: - parent = trace_mod.enclosing_parent() - else: - # A cross-task observation (plugin/RPC) cannot see the step - # contextvar; attach it to the innermost step the owning task has - # open on the trace. - parent = state.open_nodes[-1] if state.open_nodes else '' - if state.trace_id not in self.traces: - # Never buffer traces this configuration would discard anyway. - if self.trace_mode() == 'off' or len(self.traces) >= MAX_TRACES: - self.dropped += 1 - state.abandoned = True - self._unregister_state(state) - return - self.traces[state.trace_id] = state - self.trace_deadlines[state.trace_id] = time.monotonic() + TRACE_TTL_SECONDS - state.identity = identity - state.append( - family=family, - operation=operation, - mode=mode, - adapter=adapter, - runner=runner, - outcome=outcome, - synthetic=bool(synthetic), - error=error, - node=node, - parent=parent, - ) + def _common_fields(self, state: TraceState, timestamp: str) -> dict: + identity = state.identity + from ..utils import constants + + return { + 'event_id': state.event_id, + 'instance_id': identity['instance_id'], + 'workspace_uuid': identity['workspace_uuid'], + 'version': constants.semantic_version, + 'edition': constants.edition, + 'timestamp': timestamp, + 'debug': bool(state.debug), + 'sample': {'mode': state.mode, 'denominator': state.denominator}, + } + + def _enabled(self) -> bool: + try: + cfg = self.manager.telemetry_config + except Exception: + return False + return bool(cfg) and not cfg.get('disable_telemetry', False) and bool(cfg.get('url')) def _ensure_loop(self) -> None: if self.task is None or self.task.done(): @@ -252,20 +348,27 @@ class ExecutionCounters: # --------------------------------------------------------------- registry def register_trace(self, state: TraceState, execution_id) -> None: - """Alias one more execution identity onto an in-flight trace.""" + """Alias one more execution identity onto an in-flight execution.""" try: exec_id = str(execution_id or '').strip() - if not exec_id or state.abandoned: + if not exec_id: return + self.configure(state) + now = time.monotonic() + self._sweep_registry(now) if exec_id not in self.registry and len(self.registry) >= MAX_REGISTRY_KEYS: oldest = next(iter(self.registry)) self.registry.pop(oldest, None) self.registry_deadlines.pop(oldest, None) self.registry[exec_id] = state - self.registry_deadlines[exec_id] = time.monotonic() + TRACE_TTL_SECONDS + self.registry_deadlines[exec_id] = now + REGISTRY_TTL_SECONDS except Exception: return + def _resolve(self, execution_id: str) -> TraceState | None: + self._sweep_registry(time.monotonic()) + return self.registry.get(execution_id) + def _unregister_state(self, state: TraceState) -> None: for exec_id in [key for key, value in self.registry.items() if value is state]: self.registry.pop(exec_id, None) @@ -277,26 +380,68 @@ class ExecutionCounters: self.registry.pop(exec_id, None) self.registry_deadlines.pop(exec_id, None) - # ------------------------------------------------------------------ traces + # ------------------------------------------------------------------ chains def close_trace(self, state: TraceState, reason: str = 'event_done', error: str = '') -> None: - """Buffer one complete execution record when it matches the sample.""" + """Emit the one terminal ``execution_chain`` record of this execution.""" try: if state.closed: return state.closed = True - registered = self.traces.pop(state.trace_id, None) is not None - self.trace_deadlines.pop(state.trace_id, None) self._unregister_state(state) - if not registered and not state.failure_reason: + if not self._enabled(): + # Nothing can be uploaded; never grow the buffer for a record + # the sender would refuse to send. return - if (not state.stages and not state.failure_reason) or not self._trace_emitted(state): + self.configure(state) + if not self._chain_emitted(state): return - record = self._build_record(state, reason, error) - if record is not None: - self._enqueue_record(record) + self._enqueue_record(self._build_chain_record(state, reason, error)) except Exception: return + finally: + # The snapshot was read while building the chain record; stop + # collecting so a late sibling task cannot leak into the next chain. + resources_end() + + def _chain_emitted(self, state: TraceState) -> bool: + if not state.reportable or state.mode == 'off': + return False + if state.emit_nodes or state.debug: + return True + # A chain that broke is always uploaded, even when sampling excluded it. + return bool(state.failure_reason) or state.outcome() in CHAIN_FAILURES + + def _build_chain_record(self, state: TraceState, reason: str, error: str = '') -> dict: + ended_at = datetime.now(timezone.utc).isoformat() + record = { + 'event_type': 'execution_chain', + 'schema': SCHEMA, + **self._common_fields(state, ended_at), + 'closed_by': reason if reason in CLOSED_BY else 'event_done', + 'outcome': state.outcome(), + # First failure detail in this chain, so Space can show why it broke. + 'error': _bounded_error(error) + or _bounded_error(state.failure_reason) + or _bounded_error(state.first_error) + or '', + 'duration_ms': _duration_ms(state.started_at, ended_at), + 'stage_count': state.stage_count, + 'dropped_nodes': state.dropped_nodes, + 'origin': state.origin, + 'model_name': state.model_name or '', + 'adapter': state.adapter or state.node_adapter, + 'runner': state.runner or state.node_runner, + 'runner_category': state.runner_category or '', + 'pipeline_plugins': state.pipeline_plugins, + } + # Bound resources of this execution, when any lane reported them. The + # key is omitted entirely when nothing was noted, so the payload stays + # byte-identical for executions with no bindings. + resources = resources_snapshot() + if resources: + record['resources'] = resources + return record def _enqueue_record(self, record: dict) -> None: if len(self.records) >= MAX_BUFFERED_RECORDS: @@ -305,137 +450,52 @@ class ExecutionCounters: self.records.append(record) self._ensure_loop() - def _trace_emitted(self, state: TraceState) -> bool: - mode = self.trace_mode() - if mode == 'off': - return False - # A chain that failed must always be uploaded, even when no stage was - # recorded before it broke: the reason is the whole point of the trace. - if state.failure_reason: - return True - if mode == 'all': - return True - # A failed, cancelled or timed-out stage is always worth keeping. - if any(stage['outcome'] not in ('success', 'skipped') for stage in state.stages): - return True - # WebUI debug runs are deliberately traced; they stay flagged synthetic. - if state.synthetic: - return True - if mode == 'failures': - return False - try: - return int(state.trace_id[:8], 16) % self.trace_sample() == 0 - except ValueError: - return False - - def _build_record(self, state: TraceState, reason: str, error: str = '') -> dict | None: - from ..utils import constants - - identity = state.identity - if not identity.get('instance_id') or not identity.get('workspace_uuid'): - return None - ended_at = datetime.now(timezone.utc).isoformat() - stages = state.stages - observations = [] - for stage in stages: - observation = { - 'seq': stage['seq'], - 'family': stage['family'], - 'operation': stage['operation'], - 'mode': stage['mode'], - 'adapter': stage['adapter'], - 'runner': stage['runner'], - 'outcome': stage['outcome'], - 'synthetic': stage['synthetic'], - 'error': stage['error'], - 'route_ref': stage['route_ref'], - 'run_id': stage['run_id'], - 'first_seen': stage['first_seen'], - 'last_seen': stage['last_seen'], - 'trace_id': state.trace_id, - } - # Workflow position is optional: records outside any step omit both - # keys entirely, so their payloads stay byte-identical. - if stage.get('node'): - observation['node'] = stage['node'] - if stage.get('parent'): - observation['parent'] = stage['parent'] - observations.append(observation) - return { - 'event_type': 'feature_execution', - # The execution identity: one record == one chain == one Space row. - 'query_id': state.trace_id, - 'instance_id': identity['instance_id'], - 'workspace_uuid': identity['workspace_uuid'], - 'version': constants.semantic_version, - 'edition': constants.edition, - 'timestamp': ended_at, - 'trusted': True, - # First failure detail in this chain, so Space can show why it broke. - 'error': _bounded_error(error) - or state.failure_reason - or next((stage['error'] for stage in stages if stage['error']), ''), - # Legacy-compatible columns Space already maps; best effort. - 'duration_ms': _duration_ms(state.started_at, ended_at), - 'model_name': state.model_name or '', - 'adapter': state.adapter or next((stage['adapter'] for stage in stages if stage['adapter']), ''), - 'runner': state.runner or next((stage['runner'] for stage in stages if stage['runner']), ''), - 'runner_category': state.runner_category or '', - 'pipeline_plugins': state.pipeline_plugins, - 'features': { - 'schema': 1, - 'observations': observations, - 'trace': { - 'closed_by': reason, - 'started_at': state.started_at, - 'ended_at': ended_at, - 'route_ref': next((stage['route_ref'] for stage in stages if stage['route_ref']), ''), - 'run_id': next((stage['run_id'] for stage in stages if stage['run_id']), ''), - 'outcome': state.outcome(), - 'dropped_stages': state.dropped_stages, - }, - }, - } - - def _sweep_traces(self, now: float) -> None: - for trace_id in [key for key, deadline in self.trace_deadlines.items() if deadline <= now]: - state = self.traces.get(trace_id) - if state is not None: - self.close_trace(state, 'ttl') - # ----------------------------------------------------------------- flushing async def _loop(self): interval = self.flush_seconds() - last_flush = time.monotonic() - while self.records or self.traces or self.registry: - # Traces expire on a short TTL; records keep their own cadence. - await asyncio.sleep(1 if (self.traces or self.registry) else interval) - now = time.monotonic() - self._sweep_traces(now) - self._sweep_registry(now) - if self.records and now - last_flush >= interval: - last_flush = now + try: + # The outbound loop exists only to flush the record buffer. + while self.records: + await asyncio.sleep(interval) await self.flush() - self.task = None + finally: + self.task = None - async def flush(self): + async def flush(self) -> bool: + """Upload the buffered prefix; records stay buffered until accepted.""" if not self.records: - return + return True batch: list[dict] = [] size = 0 - while self.records and len(batch) < MAX_RECORDS_PER_FLUSH: - encoded = len(json.dumps(self.records[0], ensure_ascii=False, default=str)) + for record in self.records: + if len(batch) >= MAX_RECORDS_PER_FLUSH: + break + encoded = len(json.dumps(record, ensure_ascii=False, default=str)) if batch and size + encoded > MAX_FLUSH_BYTES: break - batch.append(self.records.pop(0)) + batch.append(record) size += encoded if not batch: - return + return True payload = {'records': batch} - if not await self.manager.send(payload): - await asyncio.sleep(1) - await self.manager.send(payload) + for attempt in range(MAX_FLUSH_ATTEMPTS): + try: + if await self.manager.send(payload): + # Peek-then-send: nothing is removed until the sender + # accepted the exact batch it was given. + del self.records[: len(batch)] + self.sent += len(batch) + return True + except asyncio.CancelledError: + raise + except Exception: + pass + if attempt + 1 < MAX_FLUSH_ATTEMPTS: + await asyncio.sleep(FLUSH_RETRY_BACKOFF_SECONDS * (2**attempt)) + # The batch stays buffered for the next cycle; a permanently unaccepted + # record is dropped by the buffer bound above and counted there. + return False async def shutdown(self): if self.task is not None: @@ -451,12 +511,8 @@ class ExecutionCounters: break except (Exception, asyncio.CancelledError): pass + self.dropped += len(self.records) self.records.clear() - # Executions still in flight cannot be completed during shutdown. - self.dropped += len(self.traces) - self.dropped_traces += len(self.traces) - self.traces.clear() - self.trace_deadlines.clear() self.registry.clear() self.registry_deadlines.clear() @@ -477,7 +533,7 @@ def record(ap, context, **observation): def close_trace(ap, state, reason: str = 'event_done', error: str = '') -> None: - """Best-effort trace close usable with optional telemetry and test doubles.""" + """Best-effort chain close usable with optional telemetry and test doubles.""" try: counters = _execution_counters(ap) if counters is not None: @@ -487,8 +543,10 @@ def close_trace(ap, state, reason: str = 'event_done', error: str = '') -> None: def bind_trace(ap, execution_id: str | None = None) -> trace_mod.TraceBinding: - """Bind an execution trace and alias its identity for cross-task stages.""" + """Bind an execution and alias its identity for cross-task nodes.""" binding = trace_mod.bind(execution_id) + if binding.created: + resources_begin() if execution_id: try: counters = _execution_counters(ap) @@ -500,36 +558,68 @@ def bind_trace(ap, execution_id: str | None = None) -> trace_mod.TraceBinding: @contextlib.contextmanager -def ingress(ap, reason: str = 'event_done', context=None, execution_id: str | None = None): - """Trace one execution boundary; only the owner of the trace closes it. +def ingress( + ap, + reason: str = 'event_done', + context=None, + execution_id: str | None = None, + *, + debug: bool = False, + origin: str = 'platform', + synthetic_event: str = '', + require_route: bool = False, +): + """Open one execution boundary; only the owner of the chain closes it. + + ``require_route`` starts a platform event without reporting permission; + dispatch calls ``trace.admit`` when a delivery matches. Nested entrypoints + must reuse that decision instead of implicitly admitting the parent. Nested boundaries (an event routed into a Pipeline, a Runner invoked from a - dispatch) reuse the in-flight trace instead of starting a second one, and - only alias their own execution id onto it. + dispatch) reuse the in-flight chain instead of starting a second one, and + only alias their own execution id onto it. ``debug``/``origin`` apply when + this boundary starts the chain: a WebUI/API run without a real platform + event synthesizes its virtual inbound event here, so the chain has a real + identity and a node for the event it stands for. """ binding = trace_mod.bind(execution_id) - if binding.created and context is not None: - try: - binding.state.identity = workspace_identity(context) - except Exception: - pass - if execution_id and not binding.state.abandoned: - try: - counters = _execution_counters(ap) - if counters is not None: - counters.register_trace(binding.state, execution_id) - except Exception: - pass + state = binding.state + if binding.created: + if require_route: + state.reportable = False + resources_begin() + counters = _execution_counters(ap) + if counters is not None: + if binding.created: + counters.configure(state, context, debug=debug, origin=origin) + if execution_id: + counters.register_trace(state, execution_id) + scope = None + if binding.created and synthetic_event: + scope = trace_mod.stage_scope() + node = scope.__enter__() + record( + ap, + context, + family='platform_event', + operation=str(synthetic_event)[:512], + outcome='success', + synthetic=True, + node=node, + ) failure = '' try: yield binding except BaseException as exc: - # The boundary itself failed: keep the reason on the trace so the uploaded - # payload explains the failure instead of reporting a bare outcome. + # The boundary itself failed: keep the reason on the chain so the + # uploaded payload explains the failure instead of reporting a bare + # outcome, even when sampling had excluded this execution. if binding.created: failure = f'{type(exc).__name__}: {exc}' - binding.state.mark_failure(failure) + state.mark_failure(failure) raise finally: + if scope is not None: + scope.__exit__(None, None, None) if trace_mod.unbind_root(binding): - close_trace(ap, binding.state, reason, failure) + close_trace(ap, state, reason, failure) diff --git a/src/langbot/pkg/telemetry/features.py b/src/langbot/pkg/telemetry/features.py deleted file mode 100644 index ab66f9884..000000000 --- a/src/langbot/pkg/telemetry/features.py +++ /dev/null @@ -1,102 +0,0 @@ -"""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 index 2499c7e06..e30b4fad8 100644 --- a/src/langbot/pkg/telemetry/heartbeat.py +++ b/src/langbot/pkg/telemetry/heartbeat.py @@ -13,6 +13,7 @@ credentials. from __future__ import annotations import asyncio +import time import typing from datetime import datetime, timezone @@ -36,10 +37,66 @@ class WorkspaceResourceSnapshot(typing.TypedDict): mcp_server_count: int extension_count: int skill_count: int + agent_count: int + event_processor_count: int + plugin_processor_count: int + event_binding_count: int adapters: list[str] execution_generation: int +# Agent rows are configuration, not runtime registries, so a Workspace count +# needs one scoped query. The heartbeat runs every five minutes on Cloud; a +# one-hour cache keeps the cost at one bounded query per Workspace per hour +# instead of one per cycle. Unavailable counts stay -1, never a fake zero. +_AGENT_COUNT_CACHE_TTL_SECONDS = 3600 +_agent_count_cache: dict[str, tuple[float, dict[str, int]]] = {} + + +async def _workspace_agent_kind_counts(ap: core_app.Application, workspace_uuid: str) -> dict[str, int]: + """Count one Workspace's Agents per ``kind``; empty when unavailable.""" + if not workspace_uuid: + return {} + now = time.monotonic() + cached = _agent_count_cache.get(workspace_uuid) + if cached is not None and cached[0] > now: + return dict(cached[1]) + counts: dict[str, int] = {} + try: + from ..entity.persistence import agent as persistence_agent + + statement = sqlalchemy.select(persistence_agent.Agent.kind, sqlalchemy.func.count()).group_by( + persistence_agent.Agent.kind + ) + tenant_uow = getattr(ap.persistence_mgr, 'tenant_uow', None) + if callable(tenant_uow): + async with tenant_uow(workspace_uuid): + result = await ap.persistence_mgr.execute_async(statement) + else: + result = await ap.persistence_mgr.execute_async(statement) + for kind, count in result.all(): + counts[str(kind or 'agent')] = int(count or 0) + except Exception: + counts = {} + _agent_count_cache[workspace_uuid] = (now + _AGENT_COUNT_CACHE_TTL_SECONDS, dict(counts)) + return counts + + +def _bot_processor_counts(bots) -> tuple[int, int]: + """Count plugin-processor subscriptions and event bindings across bots.""" + processors = 0 + bindings = 0 + for bot in bots: + entity = getattr(bot, 'bot_entity', bot) + declared_processors = getattr(entity, 'plugin_processors', None) + if isinstance(declared_processors, list): + processors += len(declared_processors) + declared_bindings = getattr(entity, 'event_bindings', None) + if isinstance(declared_bindings, list): + bindings += len(declared_bindings) + return processors, bindings + + async def _count( ap: core_app.Application, table, @@ -81,6 +138,10 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) - 'mcp_server_count': 0, 'extension_count': 0, 'skill_count': 0, + 'agent_count': -1, + 'event_processor_count': -1, + 'plugin_processor_count': 0, + 'event_binding_count': 0, 'adapters': [], 'execution_generation': binding.placement_generation, } @@ -91,6 +152,9 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) - for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items(): if len(key) >= 2 and key[1] in counts: counts[key[1]]['bot_count'] += 1 + processors, bindings = _bot_processor_counts([bot]) + counts[key[1]]['plugin_processor_count'] += processors + counts[key[1]]['event_binding_count'] += bindings adapter = getattr(bot, 'adapter', None) if adapter is not None and getattr(bot, 'enable', False): adapter_sets[key[1]].add(adapter.__class__.__name__) @@ -113,6 +177,10 @@ async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) - for workspace_uuid, resource in counts.items(): resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count'] resource['adapters'] = sorted(adapter_sets[workspace_uuid]) + agent_counts = await _workspace_agent_kind_counts(ap, workspace_uuid) + if agent_counts: + resource['agent_count'] = int(agent_counts.get('agent', 0)) + resource['event_processor_count'] = int(agent_counts.get('event_processor', 0)) return list(counts.values()) @@ -163,6 +231,9 @@ async def build_heartbeat_payload( 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 + processors, bindings = _bot_processor_counts(platform_mgr.bots) + features['plugin_processor_count'] = processors + features['event_binding_count'] = bindings except Exception: pass @@ -198,7 +269,7 @@ async def build_heartbeat_payload( except Exception: features['plugin_count'] = -1 - # Skill count (from Box runtime via skill manager) + # Skill count (from the Core SkillRepository cache) try: skill_mgr = getattr(ap, 'skill_mgr', None) if skill_mgr is not None: @@ -206,6 +277,13 @@ async def build_heartbeat_payload( except Exception: pass + # Agent processors: configuration rows, so one scoped count per Workspace. + # The Cloud snapshot supplies these already and overrides below. + if workspace_resource is None: + agent_counts = await _workspace_agent_kind_counts(ap, workspace_uuid) + features['agent_count'] = int(agent_counts.get('agent', 0)) if agent_counts else -1 + features['event_processor_count'] = int(agent_counts.get('event_processor', 0)) if agent_counts else -1 + if workspace_resource is not None: features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'}) diff --git a/src/langbot/pkg/telemetry/resources.py b/src/langbot/pkg/telemetry/resources.py new file mode 100644 index 000000000..ff959fd7c --- /dev/null +++ b/src/langbot/pkg/telemetry/resources.py @@ -0,0 +1,244 @@ +"""Bound-resource reporting for one execution. + +Answers "what was this execution given", never "what did it use": the ids and +names of the Plugins, MCP servers, knowledge bases, tools, skills and models +that were bound or granted to a Pipeline lane or an Agent run. Nothing here is +user content - only code-defined identifiers (uuids, plugin names, tool names) +and booleans. + +Two sources feed one execution-scoped snapshot: + +* the Host policy actually granted to the run (``ResourcePolicy`` / + materialized ``AgentResources``), which is authoritative for tools, skills and + platform APIs; +* the Runner's own configuration form (``DynamicFormItemSchema``), which is + where knowledge bases and models are selected. Selector field types are read + through :mod:`langbot.pkg.agent.runner.config_schema`, so a Runner that + declares a new selector type is picked up without a change here. + +The snapshot is attached to the terminal ``execution_chain`` record. It is +bounded twice - per list and in total - and it is best effort: telemetry must +never change execution behaviour, so every entry point swallows failures. +""" + +from __future__ import annotations + +import contextlib +import contextvars +import json +import typing + +MAX_ITEMS_PER_LIST = 64 +MAX_NAME_CHARS = 512 +MAX_RESOURCES_BYTES = 32 * 1024 + +_resources: contextvars.ContextVar[dict[str, typing.Any] | None] = contextvars.ContextVar( + 'telemetry_resources', default=None +) + + +def begin() -> None: + """Start collecting for one execution (called when a chain is opened).""" + _resources.set({}) + + +def end() -> None: + """Stop collecting; the snapshot has already been read.""" + _resources.set(None) + + +def _bucket(section: str) -> dict[str, typing.Any] | None: + root = _resources.get() + if root is None: + return None + bucket = root.get(section) + if not isinstance(bucket, dict): + bucket = {} + root[section] = bucket + return bucket + + +def _names(values: typing.Any) -> list[str]: + """Bound one list of code-defined identifiers, preserving source order.""" + if not isinstance(values, (list, tuple)): + return [] + names: list[str] = [] + seen: set[str] = set() + for value in values: + name = '' + if isinstance(value, str): + name = value + elif isinstance(value, dict): + name = str(value.get('name') or value.get('kb_id') or value.get('tool_name') or '') + if not name: + continue + name = name[:MAX_NAME_CHARS] + if name in seen: + continue + seen.add(name) + names.append(name) + if len(names) >= MAX_ITEMS_PER_LIST: + break + return names + + +def _tool_refs(values: typing.Any) -> list[dict[str, str]]: + """Bound one list of tool grants, keeping their Host source.""" + if not isinstance(values, (list, tuple)): + return [] + refs: list[dict[str, str]] = [] + seen: set[str] = set() + for value in values: + if isinstance(value, str): + name, source, source_id = value, '', '' + elif isinstance(value, dict): + name = str(value.get('tool_name') or value.get('name') or '') + source = str(value.get('tool_type') or value.get('source') or '') + raw_id = value.get('source_id') + source_id = raw_id if isinstance(raw_id, str) else '' + else: + continue + name = name[:MAX_NAME_CHARS] + if not name or name in seen: + continue + seen.add(name) + ref = {'name': name} + if source: + ref['source'] = source[:64] + if source_id: + ref['source_id'] = source_id[:MAX_NAME_CHARS] + refs.append(ref) + if len(refs) >= MAX_ITEMS_PER_LIST: + break + return refs + + +def _set(bucket: dict[str, typing.Any] | None, key: str, value: typing.Any) -> None: + if bucket is None or value in (None, [], {}, ''): + return + bucket[key] = value + + +def note_pipeline( + *, + plugins: typing.Any = None, + plugins_all: typing.Any = None, + mcp_servers: typing.Any = None, + mcp_all: typing.Any = None, + knowledge_bases: typing.Any = None, + skills: typing.Any = None, + tools: typing.Any = None, + tools_all: typing.Any = None, + runner_id: str = '', + selectors: typing.Any = None, +) -> None: + """Record the resources one Pipeline lane is bound to. + + ``plugins``/``mcp_servers`` accept ``None`` to mean "all enabled", matching + the binding model; the ``*_all`` flags make that explicit in the payload. + """ + try: + bucket = _bucket('pipeline') + if bucket is None: + return + _set(bucket, 'plugins', _names(plugins)) + _set(bucket, 'mcp_servers', _names(mcp_servers)) + _set(bucket, 'knowledge_bases', _names(knowledge_bases)) + _set(bucket, 'skills', _names(skills)) + _set(bucket, 'tools', _tool_refs(tools)) + _set(bucket, 'runner_id', str(runner_id or '')[:MAX_NAME_CHARS]) + if isinstance(selectors, dict): + cleaned = { + str(kind)[:64]: _names(fields) + for kind, fields in selectors.items() + if isinstance(fields, (list, tuple)) and fields + } + _set(bucket, 'selectors', cleaned) + if plugins_all is not None: + bucket['plugins_all'] = bool(plugins_all) + if mcp_all is not None: + bucket['mcp_all'] = bool(mcp_all) + if tools_all is not None: + bucket['tools_all'] = bool(tools_all) + except Exception: + return + + +def note_agent( + *, + runner_id: str = '', + resources: typing.Any = None, + selectors: typing.Any = None, + capabilities: typing.Any = None, + tools_all: typing.Any = None, +) -> None: + """Record the resources one Agent run was granted. + + ``resources`` is the materialized ``AgentResources`` mapping the Host + actually handed to the Runner (tools carry their source), ``selectors`` + names the Runner config-form fields that selected a resource, and + ``capabilities`` records which resource categories the Runner declared. + """ + try: + bucket = _bucket('agent') + if bucket is None: + return + _set(bucket, 'runner_id', str(runner_id or '')[:MAX_NAME_CHARS]) + if isinstance(resources, dict): + _set(bucket, 'knowledge_bases', _names(resources.get('knowledge_bases'))) + _set(bucket, 'skills', _names(resources.get('skills'))) + _set(bucket, 'models', _names(resources.get('models'))) + _set(bucket, 'tools', _tool_refs(resources.get('tools'))) + _set(bucket, 'platform_apis', _names(resources.get('platform_tools'))) + if isinstance(selectors, dict): + cleaned = { + str(kind)[:64]: _names(fields) + for kind, fields in selectors.items() + if isinstance(fields, (list, tuple)) and fields + } + _set(bucket, 'selectors', cleaned) + if isinstance(capabilities, dict): + declared = {key: bool(value) for key, value in capabilities.items() if value} + _set(bucket, 'capabilities', declared) + if tools_all is not None: + bucket['tools_all'] = bool(tools_all) + except Exception: + return + + +def snapshot() -> dict[str, typing.Any] | None: + """Return the bounded snapshot of this execution; None when nothing was noted.""" + try: + root = _resources.get() + if not isinstance(root, dict) or not root: + return None + + def encoded_size(value: typing.Any) -> int: + return len(json.dumps(value, ensure_ascii=False, default=str).encode('utf-8')) + + if encoded_size(root) <= MAX_RESOURCES_BYTES: + return root + # Over budget: shed the widest detail (per-tool grants) first, then fall + # back to a marker, so an oversized binding can never block the chain + # record or bloat the upload. + reduced = json.loads(json.dumps(root, ensure_ascii=False, default=str)) + for section in ('pipeline', 'agent'): + bucket = reduced.get(section) + if isinstance(bucket, dict): + bucket.pop('tools', None) + reduced['truncated'] = True + if encoded_size(reduced) <= MAX_RESOURCES_BYTES: + return reduced + return {'truncated': True} + except Exception: + return None + + +@contextlib.contextmanager +def collected() -> typing.Iterator[None]: + """Bind a collection scope, for callers outside an execution boundary.""" + token = _resources.set({}) + try: + yield + finally: + _resources.reset(token) diff --git a/src/langbot/pkg/telemetry/telemetry.py b/src/langbot/pkg/telemetry/telemetry.py index c36b35e3c..22ac71ae4 100644 --- a/src/langbot/pkg/telemetry/telemetry.py +++ b/src/langbot/pkg/telemetry/telemetry.py @@ -12,9 +12,6 @@ from ..utils import httpclient from .execution import ExecutionCounters -_MAX_INFLIGHT_TELEMETRY_TASKS = 8 - - class TelemetryManager: """TelemetryManager handles sending telemetry for a given application instance. @@ -27,36 +24,18 @@ class TelemetryManager: self.ap = ap self.telemetry_config: dict[str, typing.Any] = {} - self.send_tasks: list[asyncio.Task] = [] self._client: httpx.AsyncClient | None = None self.execution = ExecutionCounters(self) async def initialize(self): self.telemetry_config = self.ap.instance_config.data.get('space', {}) - async def start_send_task(self, payload: dict): - self.send_tasks = [task for task in self.send_tasks if not task.done()] - if len(self.send_tasks) >= _MAX_INFLIGHT_TELEMETRY_TASKS: - self.ap.logger.debug('Telemetry queue is full; dropping best-effort event') - return - task = asyncio.create_task(self.send(payload)) - self.send_tasks.append(task) - task.add_done_callback(self._send_task_done) - - def _send_task_done(self, task: asyncio.Task) -> None: - try: - self.send_tasks.remove(task) - except ValueError: - pass + def health(self) -> dict: + """Telemetry health for the instance liveness snapshot.""" + return self.execution.health() async def shutdown(self) -> None: await self.execution.shutdown() - tasks = list(self.send_tasks) - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - self.send_tasks.clear() if self._client is not None: await self._client.aclose() self._client = None @@ -95,45 +74,51 @@ class TelemetryManager: url = server.rstrip('/') + '/api/v1/telemetry' try: - # Sanitize payload so string fields are strings and not nulls + # Sanitize payload so string fields are strings and not nulls. + # A record batch is an envelope of whole execution records; it + # carries no top-level record fields, so only a single-record + # payload gets the legacy defaults. sanitized = dict(payload) - if 'query_id' in sanitized: - try: - sanitized['query_id'] = '' if sanitized['query_id'] is None else str(sanitized['query_id']) - except Exception: - sanitized['query_id'] = str(sanitized.get('query_id', '')) + if isinstance(sanitized.get('records'), list): + sanitized['records'] = [item for item in sanitized['records'] if isinstance(item, dict)] + else: + if 'query_id' in sanitized: + try: + sanitized['query_id'] = '' if sanitized['query_id'] is None else str(sanitized['query_id']) + except Exception: + sanitized['query_id'] = str(sanitized.get('query_id', '')) - for sfield in ( - 'adapter', - 'runner', - 'runner_category', - 'model_name', - 'version', - 'edition', - 'error', - 'timestamp', - 'event_type', - ): - if sfield not in sanitized: - continue - v = sanitized.get(sfield) - sanitized[sfield] = '' if v is None else str(v) + for sfield in ( + 'adapter', + 'runner', + 'runner_category', + 'model_name', + 'version', + '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' + # 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'] = {} + # 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'] = ( - int(sanitized['duration_ms']) if sanitized['duration_ms'] is not None else 0 - ) - except Exception: - sanitized['duration_ms'] = 0 + if 'duration_ms' in sanitized: + try: + sanitized['duration_ms'] = ( + int(sanitized['duration_ms']) if sanitized['duration_ms'] is not None else 0 + ) + except Exception: + sanitized['duration_ms'] = 0 async with self._client_context() as client: try: @@ -174,6 +159,7 @@ class TelemetryManager: else: # Detect application-level errors inside HTTP 200 responses app_err = False + j = None try: j = await httpclient.parse_json_response(resp) app_code = j.get('code') if isinstance(j, dict) else None @@ -196,6 +182,19 @@ class TelemetryManager: f'Telemetry posted to {url}, status {resp.status_code} - response: {body}' ) if not app_err: + if isinstance(sanitized.get('records'), list): + ack = j.get('data') if isinstance(j, dict) else None + if ( + not isinstance(ack, dict) + or type(ack.get('stored')) is not int + or type(ack.get('rejected')) is not int + or ack['rejected'] != 0 + or ack['stored'] != len(sanitized['records']) + ): + self.ap.logger.warning( + 'Telemetry batch was not fully acknowledged; retaining records' + ) + return False return True except asyncio.TimeoutError: self.ap.logger.debug(f'Telemetry post to {url} timed out') diff --git a/src/langbot/pkg/telemetry/trace.py b/src/langbot/pkg/telemetry/trace.py index 445bb470e..ddc5f5d05 100644 --- a/src/langbot/pkg/telemetry/trace.py +++ b/src/langbot/pkg/telemetry/trace.py @@ -1,22 +1,27 @@ -"""Content-free per-execution traces. +"""Per-execution telemetry context for the v2 execution chain. -One trace identity is one execution identity: the id a Pipeline query, inbound -platform event or Agent run is known by, so a stored chain can be joined with -the Space view of that same execution. Stage records appended under it describe -how that one execution was routed and processed, using only code-defined -identifiers. Nothing here is aggregated: a trace is a bounded, ordered sequence -for one execution. +One execution identity (a Pipeline query, inbound platform event or Agent run) +is one chain. The chain is not assembled here: every workflow node is emitted +by ``telemetry.execution`` the moment it completes, carrying its own identity +and the identity of the execution it belongs to. This module only holds the +small execution-scoped context the emitter needs: -Three bounds keep memory independent of traffic volume: +* the execution state (identity, the once-per-event sampling decision, the + execution-scoped display fields); +* the routing/run identity pinned around the block that produced a node; +* the open-step stack, which lives in a ContextVar holding *one task's own + immutable tuple*. Child tasks inherit a snapshot at creation; no shared + mutable stack exists, so sibling tasks can never corrupt each other's parent + links. -* ``MAX_STAGES`` stages per trace (further stages are counted, not kept); -* ``MAX_TRACES`` traces in flight, enforced by the sender that owns the buffer; -* a wall-clock TTL, enforced by the sender's sweep. - -The identity itself lives in a ContextVar so that asynchronous work spawned -while handling one execution inherits it, mirroring ``telemetry.platform``. -Cross-task stages (plugin/RPC work with no inherited context) are resolved by -the owner's execution-id registry instead. +Cross-task callers (plugin/RPC work with no inherited context) resolve their +execution through the owner's execution-id registry instead, which only +carries the execution state - never node payloads. Such a caller owns no step +of its own, yet its work is still caused by a step of the chain: the sandbox +tool a Runner invoked, the platform API a plugin called back over RPC. The +execution state therefore also keeps the ordered set of steps any task +currently has open, and a record emitted with no open step of its own attaches +to the innermost one of those instead of becoming a root of the chain. """ from __future__ import annotations @@ -27,64 +32,87 @@ import typing from datetime import datetime, timezone from uuid import uuid4 -MAX_STAGES = 32 - _current: contextvars.ContextVar['TraceState | None'] = contextvars.ContextVar('telemetry_trace', default=None) _route: contextvars.ContextVar[str] = contextvars.ContextVar('telemetry_trace_route', default='') _run: contextvars.ContextVar[str] = contextvars.ContextVar('telemetry_trace_run', default='') -# Open workflow step nodes, innermost last. Records emitted inside a step carry -# the innermost node as their ``parent``; the step's own record carries the one -# below it (its enclosing step), never itself. -_node: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar('telemetry_trace_node', default=()) +_reporting_suppressed: contextvars.ContextVar[bool] = contextvars.ContextVar( + 'telemetry_reporting_suppressed', default=False +) +# Open workflow step frames of the current task, innermost last. The tuple is +# never mutated in place: opening a step publishes a new tuple on this task's +# context only, and closing it restores the previous one. +_node: contextvars.ContextVar[tuple['NodeFrame', ...]] = contextvars.ContextVar('telemetry_trace_node', default=()) def _now() -> str: return datetime.now(timezone.utc).isoformat() +class NodeFrame(typing.NamedTuple): + """One workflow step open in the task that opened it.""" + + node_id: str + seq: int + started_at: str + + class TraceState: - """Bounded stage buffer for exactly one execution.""" + """The small per-execution context; node payloads never accumulate here.""" __slots__ = ( - 'trace_id', - 'execution_id', - 'started_at', - 'stages', - 'dropped_stages', - 'synthetic', - 'sequence', - 'node_sequence', - 'open_nodes', + 'event_id', 'identity', - 'abandoned', + 'configured', + 'reportable', + 'mode', + 'denominator', + 'emit_nodes', + 'debug', + 'origin', + 'started_at', + 'node_seq', + 'root_seen', + 'stage_count', + 'dropped_nodes', + 'last_outcome', + 'first_error', 'failure_reason', + 'runner_failed', 'closed', 'adapter', 'runner', 'runner_category', 'model_name', 'pipeline_plugins', + 'node_adapter', + 'node_runner', + 'open_frames', ) def __init__(self, execution_id: str | None = None) -> None: - # The execution identity is the trace identity so a stored chain can be - # joined to the Space view of that same Pipeline query / event / run. - self.execution_id = str(execution_id).strip() if execution_id else '' - self.trace_id = self.execution_id or str(uuid4()) - self.started_at = _now() - self.stages: list[dict[str, typing.Any]] = [] - self.dropped_stages = 0 - self.synthetic = False - self.sequence = 0 - # Node ids number the workflow steps of this one execution ("n1", "n2"...). - self.node_sequence = 0 - # Mirror of the open step stack, readable from any task: cross-task - # observations (plugin/RPC) resolve their parent through this. - self.open_nodes: tuple[str, ...] = () + # The execution identity every record of this chain carries. + self.event_id = str(execution_id).strip() if execution_id else str(uuid4()) self.identity: dict[str, str] = {} - # Set when the sender refused to buffer this trace: stop appending. - self.abandoned = False + self.configured = False + # Platform ingress starts pending; an actual route admits the shared + # execution before work starts. Independent API/debug runs are eligible. + self.reportable = not reporting_suppressed() + # Sampling, decided exactly once per event (at ingress or first touch). + self.mode = 'all' + self.denominator = 1 + self.emit_nodes = False + self.debug = False + self.origin = 'platform' + self.started_at = _now() + self.node_seq = 0 + self.root_seen = False + self.stage_count = 0 + self.dropped_nodes = 0 + self.last_outcome = '' + self.first_error = '' self.failure_reason = '' + # Set when a runner node already reported a non-success outcome. + self.runner_failed = False self.closed = False # Execution-scoped record fields attached by the owning lane when known. self.adapter = '' @@ -92,54 +120,55 @@ class TraceState: self.runner_category = '' self.model_name = '' self.pipeline_plugins: typing.Any = None + # First adapter/runner seen on a node, used for the chain record. + self.node_adapter = '' + self.node_runner = '' + # Steps currently open anywhere in this chain, in the order they were + # opened. A record emitted by a task that owns no step of its own + # attaches to the innermost entry here. + self.open_frames: dict[str, NodeFrame] = {} - def append( + def allocate_seq(self) -> int: + """Mint the next chain-local node order.""" + seq = self.node_seq + self.node_seq += 1 + return seq + + def open_lane(self) -> str: + """The innermost step open in this chain, whichever task opened it.""" + frames = list(self.open_frames) + return frames[-1] if frames else '' + + def open_step(self, frame: NodeFrame) -> None: + self.open_frames[frame.node_id] = frame + + def close_step(self, frame: NodeFrame) -> None: + self.open_frames.pop(frame.node_id, None) + + def note_node( self, *, - family: str, - operation: str, - mode: str, - adapter: str, - runner: str, outcome: str, - synthetic: bool, + adapter: str = '', + runner: str = '', + family: str = '', error: str = '', - node: str = '', - parent: str = '', ) -> None: - if len(self.stages) >= MAX_STAGES: - self.dropped_stages += 1 - return - seen = _now() - entry = { - 'family': family, - 'operation': operation, - 'mode': mode, - 'adapter': adapter, - 'runner': runner, - 'outcome': outcome, - 'synthetic': bool(synthetic), - 'seq': self.sequence, - 'route_ref': _route.get(), - 'run_id': _run.get(), - 'first_seen': seen, - 'last_seen': seen, - 'error': error, - } - # Only workflow steps carry these; legacy records stay byte-identical. - if node: - entry['node'] = node - if parent: - entry['parent'] = parent - self.stages.append(entry) - self.sequence += 1 - if synthetic: - self.synthetic = True - - def allocate_node(self) -> str: - """Mint the next step-node id for this execution ("n1", "n2", ...).""" - self.node_sequence += 1 - return f'n{self.node_sequence}' + """Remember only what the terminal chain record needs from a node.""" + self.stage_count += 1 + self.last_outcome = outcome or 'unknown' + if adapter and not self.node_adapter: + self.node_adapter = adapter + if runner and not self.node_runner: + self.node_runner = runner + if family == 'runner' and outcome not in ('success', 'skipped'): + # The lane records its own failed runner step only when the runner + # orchestrator has not already landed one. + self.runner_failed = True + if outcome in ('failed', 'cancelled', 'timeout') and error and not self.first_error: + # The chain must explain itself even when the break was reported as + # a failed node rather than as an exception at the boundary. + self.first_error = error def mark_failure(self, reason: str) -> None: """Remember why this chain broke so the payload can explain itself.""" @@ -147,18 +176,10 @@ class TraceState: self.failure_reason = reason def outcome(self) -> str: - """Terminal outcome of this chain, for space-side display. - - A chain that carries a failure reason never reports ``success``: the - reason is the point of the trace. - """ - if self.stages: - last = self.stages[-1]['outcome'] - else: - last = 'unknown' - if self.failure_reason and last in ('success', 'skipped', 'unknown'): + """Terminal outcome of this chain; a broken chain never reports success.""" + if self.failure_reason and self.last_outcome in ('', 'success', 'skipped', 'unknown'): return 'failed' - return last + return self.last_outcome or 'unknown' class TraceBinding(typing.NamedTuple): @@ -168,9 +189,9 @@ class TraceBinding(typing.NamedTuple): def bind(execution_id: str | None = None) -> TraceBinding: - """Start a trace unless one is already in flight in this context. + """Start a chain unless one is already in flight in this context. - The execution id becomes the trace id only when this call starts the trace; + The execution id becomes the chain identity only when this call starts it; a nested boundary keeps the enclosing execution's identity. """ existing = _current.get() @@ -181,7 +202,7 @@ def bind(execution_id: str | None = None) -> TraceBinding: def unbind(binding: TraceBinding) -> bool: - """Detach this binding. Returns True when this caller owns the trace.""" + """Detach this binding. Returns True when this caller owns the chain.""" if binding.token is not None: try: _current.reset(binding.token) @@ -192,7 +213,7 @@ def unbind(binding: TraceBinding) -> bool: def unbind_root(binding: TraceBinding) -> bool: - """Detach only when this caller started the trace, else leave it in place.""" + """Detach only when this caller started the chain, else leave it in place.""" if not binding.created: return False return unbind(binding) @@ -202,39 +223,89 @@ def current() -> TraceState | None: return _current.get() +def reporting_suppressed() -> bool: + return _reporting_suppressed.get() + + +def admit() -> None: + """Admit a matched delivery without revoking any concurrent sibling's choice.""" + state = current() + if state is not None and not state.closed and not reporting_suppressed(): + state.reportable = True + + +@contextlib.contextmanager +def suppress_reporting(): + """Keep discarded branch side effects local even if a sibling is admitted.""" + token = _reporting_suppressed.set(True) + try: + yield + finally: + _reporting_suppressed.reset(token) + + +def node_stack() -> tuple[NodeFrame, ...]: + """This task's own open-step frames, innermost last.""" + return _node.get() + + def current_parent() -> str: """The step node that records emitted right now attach to ('' when none).""" stack = _node.get() - return stack[-1] if stack else '' + return stack[-1].node_id if stack else '' def enclosing_parent() -> str: """The step enclosing the innermost open one; '' when it is a root step.""" stack = _node.get() - return stack[-2] if len(stack) > 1 else '' + return stack[-2].node_id if len(stack) > 1 else '' + + +def resolve_node( + state: TraceState, + node: str, + fallback_started_at: str, +) -> tuple[str, int, str, str]: + """Resolve one record to ``(node_id, seq, started_at, parent_node_id)``. + + A record stamped with a ``node`` the calling task has open reuses that + step's identity, order and start time; anything else (a plain observation, + or a node opened by another task) is a fresh node under the innermost step + open in this task. A task that inherited no step at all - plugin/RPC work + that only knows the execution - attaches to the innermost step the chain + has open, which is the step that caused the work. + """ + stack = _node.get() + if node: + for index in range(len(stack) - 1, -1, -1): + frame = stack[index] + if frame.node_id == node: + parent = stack[index - 1].node_id if index > 0 else '' + return frame.node_id, frame.seq, frame.started_at, parent + parent = stack[-1].node_id if stack else state.open_lane() + return node or uuid4().hex, state.allocate_seq(), fallback_started_at, parent @contextlib.contextmanager def stage_scope() -> typing.Iterator[str]: """Open one workflow step node for the duration of the block. - Allocates a node id unique within the execution, publishes it as the current - parent so that nested observations recorded inside the block attach to it, - and yields that id so the step's own records can be stamped with ``node=``. - Outside any trace nothing is allocated and the empty id is yielded. + Allocates a globally unique node id and the chain-local order, publishes the + frame on this task's own stack so nested observations attach to it as their + parent, and yields the node id so the step's own record can be stamped with + ``node=``. Outside any execution nothing is allocated and '' is yielded. """ state = _current.get() if state is None: yield '' return - node = state.allocate_node() - previous_open = state.open_nodes - state.open_nodes = previous_open + (node,) - token = _node.set(_node.get() + (node,)) + frame = NodeFrame(uuid4().hex, state.allocate_seq(), _now()) + token = _node.set(_node.get() + (frame,)) + state.open_step(frame) try: - yield node + yield frame.node_id finally: - state.open_nodes = previous_open + state.close_step(frame) try: _node.reset(token) except (ValueError, RuntimeError): @@ -258,7 +329,7 @@ def reset_run(token: typing.Any) -> None: @contextlib.contextmanager def scope(*, route_ref: str | None = None) -> typing.Iterator[None]: - """Pin routing identity for stages recorded inside this block.""" + """Pin routing identity for nodes recorded inside this block.""" if not route_ref: yield return @@ -270,3 +341,11 @@ def scope(*, route_ref: str | None = None) -> typing.Iterator[None]: _route.reset(token) except (ValueError, RuntimeError): pass + + +def current_route() -> str: + return _route.get() + + +def current_run() -> str: + return _run.get() diff --git a/src/langbot/pkg/utils/execution_lease.py b/src/langbot/pkg/utils/execution_lease.py new file mode 100644 index 000000000..037441de7 --- /dev/null +++ b/src/langbot/pkg/utils/execution_lease.py @@ -0,0 +1,145 @@ +"""Host liveness leases; no age-based timeout for live executions.""" + +import asyncio +import datetime +import uuid + +import sqlalchemy as sa + +from ..entity.persistence.agent_run import AgentRun +from ..entity.persistence.monitoring import MonitoringMessage, MonitoringError +from .inflight import inflight_hub + +OWNER_ID = str(uuid.uuid4()) +LEASE_SECONDS = 120 +INTERRUPTED_REASON = 'Execution interrupted: the LangBot host stopped renewing its lease.' + + +def host_execution_lease(): + return { + 'execution_owner_id': OWNER_ID, + 'execution_lease_expires_at': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + + datetime.timedelta(seconds=LEASE_SECONDS), + } + + +async def reconcile_execution_leases(engine, workspace_uuid, *, now=None, owner_id=OWNER_ID): + """Renew our live leases, then conditionally finish bounded expired batches. + + Never adopt a lease that already expired: another host may be reaping it. + Remote queued/claimed runs and pre-upgrade rows without ownership are excluded. + """ + now = now or datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + changed = 0 + workspaces = set() + for model, statuses, scope, identity in ( + (AgentRun, ['running'], AgentRun.workspace_id, AgentRun.run_id), + (MonitoringMessage, ['pending'], MonitoringMessage.workspace_uuid, MonitoringMessage.id), + ): + conditions = [model.status.in_(statuses), scope == workspace_uuid] + if model is AgentRun: + conditions.extend([AgentRun.queue_name.is_(None), AgentRun.claimed_by_runtime_id.is_(None)]) + async with engine.begin() as connection: + if engine.dialect.name == 'postgresql': + await connection.execute( + sa.text("SELECT set_config('langbot.workspace_uuid', :workspace, true)"), + {'workspace': workspace_uuid}, + ) + await connection.execute( + sa.update(model) + .where( + *conditions, + model.execution_owner_id == owner_id, + model.execution_lease_expires_at > now, + ) + .values(execution_lease_expires_at=now + datetime.timedelta(seconds=LEASE_SECONDS)) + ) + expired = [*conditions, model.execution_lease_expires_at <= now] + rows = ( + await connection.execute( + sa.select(identity, scope).where(*expired).order_by(model.execution_lease_expires_at).limit(500) + ) + ).all() + if not rows: + continue + values = ( + {'status': 'failed', 'status_reason': INTERRUPTED_REASON, 'finished_at': now, 'updated_at': now} + if model is AgentRun + else { + 'status': 'error', + 'level': 'error', + } + ) + if model is MonitoringMessage: + messages = ( + ( + await connection.execute( + sa.select(model).where( + model.id.in_([row[0] for row in rows]), + ) + ) + ) + .mappings() + .all() + ) + for message in messages: + result = await connection.execute( + sa.update(model) + .where( + *expired, + model.id == message['id'], + ) + .values(**values) + ) + changed += result.rowcount + if result.rowcount: + await connection.execute( + sa.insert(MonitoringError).values( + id=str(uuid.uuid4()), + timestamp=now, + error_type='ExecutionInterrupted', + error_message=INTERRUPTED_REASON, + message_id=message['id'], + **{ + key: message[key] + for key in ( + 'workspace_uuid', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + ) + }, + ) + ) + else: + result = await connection.execute( + sa.update(model) + .where( + *expired, + identity.in_([row[0] for row in rows]), + ) + .values(**values) + ) + changed += result.rowcount + workspaces.update(row[1] for row in rows) + for workspace in workspaces: + inflight_hub.notify(workspace) + return changed + + +async def maintain_execution_leases(ap): + while True: + try: + count = 0 + for binding in await ap.workspace_service.list_active_execution_bindings(): + count += await reconcile_execution_leases( + ap.persistence_mgr.get_db_engine(), + binding.workspace_uuid, + ) + if count: + ap.logger.info(f'Marked {count} interrupted executions as failed after host lease expiry') + except Exception: + ap.logger.exception('Execution lease maintenance failed') + await asyncio.sleep(20) diff --git a/src/langbot/pkg/utils/inflight.py b/src/langbot/pkg/utils/inflight.py new file mode 100644 index 000000000..8d6b8c0b4 --- /dev/null +++ b/src/langbot/pkg/utils/inflight.py @@ -0,0 +1,100 @@ +"""Bounded, process-local fan-out. One snapshot producer per watched Workspace. + +Writes wake the producer; quiet workspaces reconcile every 15 seconds to cover +external workers. A shared broker can replace notify without changing clients. +No payloads or subscriptions survive the final viewer disconnecting. +""" + +import asyncio +import contextlib +import logging +import time + + +logger = logging.getLogger(__name__) + + +class InflightHub: + def __init__(self): + self.channels = {} + self.run_workspaces = {} + + def notify(self, workspace): + channel = self.channels.get(workspace) + if channel: + channel['dirty'].set() + + def notify_run(self, run_id, event_type): + workspace = self.run_workspaces.get(run_id) + channel = self.channels.get(workspace) + if channel and channel['phases'].get(run_id) != event_type: + channel['phases'][run_id] = event_type + channel['dirty'].set() + + def track(self, workspace, run_ids): + self.run_workspaces = {key: value for key, value in self.run_workspaces.items() if value != workspace} + if workspace not in self.channels: + return + self.run_workspaces.update({run_id: workspace for run_id in run_ids}) + if workspace in self.channels: + phases = self.channels[workspace]['phases'] + self.channels[workspace]['phases'] = {key: value for key, value in phases.items() if key in run_ids} + + async def watch(self, workspace, loader): + if sum(len(c['queues']) for c in self.channels.values()) >= 1024: + raise RuntimeError('Too many monitoring connections') + channel = self.channels.get(workspace) + if channel is None: + channel = dict(queues=set(), dirty=asyncio.Event(), phases={}, snapshot=None) + self.channels[workspace] = channel + channel['task'] = asyncio.create_task(self._produce(channel, loader)) + if len(channel['queues']) >= 64: + raise RuntimeError('Too many Workspace monitoring connections') + queue = asyncio.Queue(maxsize=1) + channel['queues'].add(queue) + if channel['snapshot'] is not None: + queue.put_nowait(channel['snapshot']) + try: + # Reconnect periodically so HTTP authentication/authorization is renewed. + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + try: + yield await asyncio.wait_for(queue.get(), timeout=min(10, deadline - time.monotonic())) + except asyncio.TimeoutError: + yield {'kind': 'heartbeat'} + finally: + channel['queues'].discard(queue) + if not channel['queues']: + self.channels.pop(workspace, None) + self.track(workspace, []) + channel['task'].cancel() + with contextlib.suppress(asyncio.CancelledError): + await channel['task'] + + async def _produce(self, channel, loader): + previous = None + while True: + channel['dirty'].clear() + try: + snapshot = await loader() + frame = {'kind': 'snapshot', 'data': snapshot} + except Exception: + if previous is None or previous['kind'] != 'unavailable': + logger.exception('Failed to load in-flight monitoring snapshot') + frame = {'kind': 'unavailable'} + # Heartbeats are sent separately; unchanged snapshots generate no traffic. + if frame != previous: + channel['snapshot'] = frame + for queue in tuple(channel['queues']): + if queue.full(): + queue.get_nowait() + queue.put_nowait(frame) + previous = frame + await asyncio.sleep(0.75) + try: + await asyncio.wait_for(channel['dirty'].wait(), timeout=15) + except asyncio.TimeoutError: + pass + + +inflight_hub = InflightHub() diff --git a/src/langbot/pkg/utils/python_workspace.py b/src/langbot/pkg/utils/python_workspace.py new file mode 100644 index 000000000..96794eb9e --- /dev/null +++ b/src/langbot/pkg/utils/python_workspace.py @@ -0,0 +1,191 @@ +"""Python project detection and sandbox-local environment bootstrap helpers.""" + +from __future__ import annotations + +import os +import textwrap + + +PYTHON_MANIFEST_FILES = ( + 'requirements.txt', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', +) + + +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 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 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', + state_path: str | None = None, +) -> str: + """Wrap a command with a reusable sandbox-local Python env bootstrap.""" + + writable_state_path = state_path or mount_path + bootstrap = textwrap.dedent( + rf""" + set -e + + _LB_VENV_DIR="{writable_state_path}/.venv" + _LB_META_DIR="{writable_state_path}/.langbot" + _LB_META_FILE="$_LB_META_DIR/python-env.json" + _LB_LOCK_DIR="$_LB_META_DIR/python-env.lock" + _LB_TMP_DIR="{writable_state_path}/.tmp" + _LB_PIP_CACHE_DIR="{writable_state_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}" + max_manifest_bytes = 10 * 1024 * 1024 + 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 + if os.path.getsize(path) > max_manifest_bytes: + raise RuntimeError( + f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}" + ) + manifest_files.append(rel) + with open(path, "rb") as handle: + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + 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' + + +__all__ = [ + 'PYTHON_MANIFEST_FILES', + 'list_python_manifest_files', + 'normalize_host_path', + 'should_prepare_python_env', + 'wrap_python_command_with_env', +] diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 9e5a0f7ac..97650b8d8 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -224,7 +224,8 @@ storage: max_object_read_bytes: 10485760 # Media content cache (images & attachments externalized from monitoring and pipelines) media_cache: - # Retention period in days for cached media (defaults to 30 days) + # Retention for CAS files and historical inline images (defaults to 30 days). + # Inline images expire by record creation time; message text is retained. retention_days: 30 # Maximum disk storage for cached media in MB (0 means unlimited, defaults to 0) max_size_mb: 0 @@ -357,13 +358,20 @@ monitoring: # Prevent one large Workspace backlog from monopolizing PostgreSQL. # Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN. max_batches_per_table_per_run: 4 +skills: + # Core-owned SkillStore. Skill discovery, activation, resources, CRUD and + # revisions remain available when Box is disabled. + # TODO(next-major): change the fresh-install default to './data/skills' + # after the online-upgrade window for the historical Box path closes. + root: './data/box/skills' + 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. + # subprocess. Disabling Box disables execution-backed features: native + # sandbox tools (exec/read/write/edit/glob/grep), agent-side skill + # registration, and stdio-mode MCP servers. Skill management, activation, + # and read-only package resources remain available without Box. enabled: true # 'host' runs commands directly as the Box Runtime user without sandbox # isolation. It is never auto-selected and is only for trusted local @@ -408,11 +416,10 @@ box: workspace_quota_mb: 0 readiness_cache_sec: 15 local: - profile: 'default' + profile: 'default' # Network enabled by default. Use 'offline' to disable network by 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' @@ -444,11 +451,34 @@ space: # heartbeat and execution reporting; restart after changing this setting. disable_telemetry: false # Per-event execution traces: off | failures | sampled | all. - # 'sampled' keeps every failed run plus every N-th successful one - # (N is execution_trace_sample) and always keeps WebUI debug runs. - execution_trace: sampled + # Every eligible execution is reported by default: one record per workflow + # node plus one terminal chain record. 'failures' keeps only broken chains; + # 'sampled' keeps broken chains plus about 1/N of successful ones, selected + # by event hash (N is execution_trace_sample); 'off' reports no executions. + # Unmatched/discarded platform events are never reported unless a plugin + # subscription matches. Matched delivery failures and valid interaction + # resumptions remain eligible, as do independent API/WebUI executions. + # Cloud uses 'all' but still applies this route eligibility filter. + execution_trace: all execution_trace_sample: 20 # How often buffered execution records are uploaded, in seconds. - # Records of complete executions are batched as {"records": [...]}; a - # still-running execution is never uploaded half-written. + # Complete execution records are batched as {"records": [...]}; records are + # removed from the buffer only once the sender accepted them. telemetry_flush_seconds: 180 + +# Host-owned LLM policy. Models/calls may override through extra_args.request_policy. +llm_request_policy: + retries: 0 + total_timeout: 300 + first_event_timeout: 120 + stream_idle_timeout: 120 + connect_timeout: 10 + read_timeout: 120 + write_timeout: 30 + pool_timeout: 10 + backoff_base: 0.5 + backoff_max: 8 + attribution: true + # Optional: x-session-id, x-litellm-session-id, x-langbot-conversation-id. + # OpenRouter uses x-session-id automatically; other providers default to none. + conversation_header: null diff --git a/src/langbot/templates/default-pipeline-config.json b/src/langbot/templates/default-pipeline-config.json index 23666ab03..b692c3e0e 100644 --- a/src/langbot/templates/default-pipeline-config.json +++ b/src/langbot/templates/default-pipeline-config.json @@ -55,7 +55,7 @@ }, "misc": { "exception-handling": "show-hint", - "failure-hint": "Request failed.", + "failure-hint": "", "at-sender": true, "quote-origin": true, "track-function-calls": false, diff --git a/src/langbot/templates/dingtalk_stream_card.json b/src/langbot/templates/dingtalk_stream_card.json new file mode 100644 index 000000000..e11cb3154 --- /dev/null +++ b/src/langbot/templates/dingtalk_stream_card.json @@ -0,0 +1,6 @@ +{ + "editorData": "{\"schemaVersion\":\"3.0.0\",\"schema\":{\"config\":null,\"componentsMap\":[{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIPending\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIPending\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardStatusContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardStatusContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"BaseText\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"BaseText\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContent\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContent\",\"children\":[]},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"MarkdownBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"MarkdownBlock\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"CollapsePanel\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"CollapsePanel\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIGenerationProcessing\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIGenerationProcessing\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Feedback\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Feedback\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Grid\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Grid\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Image\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Image\"}],\"componentsTree\":[{\"componentName\":\"AICardContainer\",\"id\":\"node_root\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enablePending\":true,\"enableWriting\":true,\"enableDoing\":true,\"enableFailed\":true,\"summaryContent\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"enableTitle\":false,\"flowStatusVar\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"flowStatus\"},\"operationPenalType\":\"prompt\",\"enableFlowAbort\":false,\"innerOffset\":0,\"enableGradientBorder\":true,\"cardSizeMode\":\"adaptive\",\"cardSizeHeightMode\":\"adaptive\",\"cardSizeWidthMode\":\"adaptive\",\"cardSizeHeight\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":226,\"variable\":\"\",\"variableType\":\"global\"},\"hasBackground\":false,\"backgroundType\":\"Standard\",\"standardBackgroundColor\":\"gray\",\"backgroundColor\":\"#F6F6F6\",\"darkModeBackgroundColor\":\"#3C3C3C\",\"enableEngineUpgrade\":false,\"enableExposeStatPoint\":false,\"enableDebugTool\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_pending\",\"props\":{\"status\":1,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"处理中\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AIPending\",\"id\":\"node_pending_inner\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"pendingTip\":{\"type\":\"dynamicString\",\"content\":\"正在处理…\",\"i18n\":false},\"style\":\"embed\",\"hideIcon\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_writing\",\"props\":{\"status\":2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":false,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"回复中\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_writing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"CollapsePanel\",\"id\":\"langbot_reasoning_2\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_2\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程 · 输出中\"},\"contentVisible\":true,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_2_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_2_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_doing\",\"props\":{\"status\":4,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":false,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"执行中\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_doing_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"CollapsePanel\",\"id\":\"langbot_reasoning_4\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_4\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程 · 输出中\"},\"contentVisible\":true,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_4_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_4_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_done\",\"props\":{\"status\":3,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":false,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"已完成\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_done_content\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"CollapsePanel\",\"id\":\"langbot_reasoning_3\",\"props\":{\"id\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"reasoning_3\"},\"title\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"思考过程\"},\"contentVisible\":false,\"marginLeft\":12,\"marginRight\":12,\"marginBottom\":8,\"marginTop\":4,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"hasReasoning\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"margin\":-2,\"innerOffset\":0},\"title\":\"思考过程\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"MarkdownBlock\",\"id\":\"node_3_reasoning\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"reasoning\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"reasoning\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_3_answer\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"answer\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":8,\"marginBottom\":8,\"marginLeft\":12,\"marginRight\":12},\"title\":\"answer\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"Grid\",\"id\":\"langbot_operation_row\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":true,\"isAutoWidth\":false,\"autoWidth\":false,\"isFixedWidth\":false,\"fixWidth\":false,\"width\":100,\"childGravity\":\"leftTop\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":12,\"marginRight\":12,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"none\"},\"children\":[{\"componentName\":\"Grid\",\"id\":\"langbot_link\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":false,\"isAutoWidth\":true,\"autoWidth\":true,\"isFixedWidth\":false,\"fixWidth\":false,\"childGravity\":\"leftCenter\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":0,\"marginRight\":8,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"url\",\"urlType\":\"all\",\"url\":{\"type\":\"dynamicLink\",\"valueType\":\"fixed\",\"value\":\"https://langbot.app?utm_source=dingtalk&utm_medium=bot_card&utm_campaign=langbot\",\"variable\":\"\",\"variableType\":\"global\"},\"height\":28},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"Image\",\"id\":\"langbot_logo\",\"props\":{\"images\":{\"defaultLang\":\"zh_Hans\",\"content\":{\"zh_Hans\":{\"value\":\"https://langbot.app/logo-blue.png\",\"valueType\":\"fixed\",\"type\":\"dynamicImage\"}},\"i18n\":false},\"darkModeImages\":{\"defaultLang\":\"zh_Hans\",\"content\":{\"zh_Hans\":{\"value\":\"\",\"valueType\":\"fixed\",\"type\":\"dynamicImage\"}},\"i18n\":false},\"single\":true,\"height\":{\"type\":\"dynamicWidth\",\"valueType\":\"fixed\",\"value\":12,\"variableType\":\"global\",\"variable\":\"\",\"full\":false,\"adaptive\":false},\"width\":{\"type\":\"dynamicWidth\",\"valueType\":\"fixed\",\"value\":12,\"variableType\":\"global\",\"variable\":\"\",\"full\":false,\"adaptive\":false},\"cornerRadius\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"scaleType\":\"fitCenter\",\"enablePreview\":false,\"marginRight\":4,\"marginTop\":0,\"marginBottom\":0,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"adaptiveSize\":false,\"fixedRatio\":false,\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"enableBorder\":false,\"borderColor\":\"#297f8790\",\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"aspectRatio\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"offsetLeft\":0,\"offsetRight\":0,\"marginLeft\":0,\"innerOffset\":0,\"enableStatPoint\":false,\"margin\":-2},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"BaseText\",\"id\":\"langbot_name\",\"props\":{\"text\":{\"type\":\"dynamicString\",\"content\":\"LangBot\",\"i18n\":false},\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"iconType\":\"iconCode\",\"iconFont\":{\"type\":\"icon\",\"iconType\":\"ddIcon\",\"icon\":\"icon_question_fill\"},\"icon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"darkIcon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"autoWidth\":true,\"maxWidth\":0,\"fixedWidth\":0,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":1},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level3_base_color\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"left\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"small\",\"customFontSize\":12,\"customFontLineHeight\":16,\"bold\":false,\"italic\":false,\"strikeThrough\":false,\"lineHeight\":\"normal\",\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"autoMaxWidth\":false,\"innerOffset\":0,\"enableIcon\":false,\"widthMode\":\"wrap_content\"},\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"Grid\",\"id\":\"langbot_native_operations\",\"props\":{\"direction\":\"horizontal\",\"isAutoHeight\":true,\"isAutoWidth\":false,\"autoWidth\":false,\"isFixedWidth\":false,\"fixWidth\":false,\"width\":100,\"childGravity\":\"leftTop\",\"hasBackground\":false,\"hasGradientBackground\":false,\"hasBorder\":false,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"actionType\":\"none\"},\"children\":[{\"componentName\":\"Feedback\",\"id\":\"node_ocmv2bczc8a\",\"props\":{\"params\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginConfigurable\":false,\"enableLikeDislike\":true,\"enableCopy\":true,\"copyType\":\"common\",\"copyVariable\":{\"type\":\"variableValue\",\"variable\":\"answer\",\"variableType\":\"global\",\"varType\":\"markdown\"},\"enableDivider\":false,\"margin\":-2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"innerOffset\":0,\"enableRefresh\":false,\"actionType\":\"none\",\"actionId\":{\"type\":\"dynamicString\",\"content\":\"sys_action_like_component\",\"i18n\":false},\"refreshParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"refreshSuccessCondition\":{\"op\":\"and\",\"conditions\":[]},\"refreshSuccessToast\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"refreshFailureToast\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"refreshVisible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}}},\"title\":\"操作区\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[]}],\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}],\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_failed\",\"props\":{\"status\":5,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"失败\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_failed_content\",\"props\":{\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"innerOffset\":0,\"disabledWhileForward\":false,\"statPoint\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"statPointParams\":[{\"type\":\"fixed\",\"variable\":\"\",\"value\":\"\",\"name\":\"\",\"variableType\":\"global\",\"id\":\"1\"}],\"margin\":-2,\"transformToEventChain\":false,\"enableStatPoint\":false},\"hidden\":false,\"title\":\"\",\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"BaseText\",\"id\":\"node_failed_text\",\"props\":{\"text\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"操作失败,请稍后重试。\"},\"hoverText\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"iconType\":\"iconCode\",\"iconFont\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"ddIcon\"},\"icon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"darkIcon\":{\"type\":\"dynamicLink\",\"value\":\"\",\"valueType\":\"fixed\",\"variable\":\"\",\"variableType\":\"global\"},\"autoWidth\":false,\"maxWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"fixedWidth\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":0,\"variable\":\"\",\"variableType\":\"global\"},\"marginLeft\":10,\"marginRight\":10,\"marginTop\":10,\"marginBottom\":10,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level1_base_color\",\"variable\":\"\",\"variableType\":\"global\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"center\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"middle\",\"customFontSize\":15,\"customFontLineHeight\":22,\"bold\":false,\"italic\":false,\"strikeThrough\":false,\"lineHeight\":\"normal\",\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"autoMaxWidth\":false,\"innerOffset\":0,\"enableIcon\":false,\"widthMode\":\"match_parent\",\"margin\":-2},\"title\":\"基础文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"}]}]}]}],\"i18n\":{},\"version\":\"1.0.0\"},\"mockData\":{\"cardData\":{\"flowStatus\":\"3\",\"content\":\"\",\"reasoning\":\"> 这里展示模型返回的思考内容。\",\"answer\":\"这里展示正式回答。\",\"query\":\"\",\"config\":{\"autoLayout\":true},\"brand\":\"\",\"hasReasoning\":\"true\",\"autoLayout\":\"true\"},\"cardPrivateData\":{},\"localData\":{\"flowStatus\":\"\",\"_cardFoldStatusLocalDataKey\":\"\"},\"richTextData\":{}},\"renderContext\":{\"regenerateEnabled\":\"1\",\"regenerateIndex\":\"2\",\"regenerateTotal\":\"5\"},\"editVersion\":0,\"customWidgetInfo\":\"\",\"useCustomWidgetInfo\":false,\"variableList\":[{\"id\":\"content\",\"name\":\"content\",\"type\":\"markdown\",\"description\":\"content\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"reasoning\",\"name\":\"reasoning\",\"type\":\"markdown\",\"description\":\"reasoning\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"answer\",\"name\":\"answer\",\"type\":\"markdown\",\"description\":\"answer\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"flowStatus\",\"name\":\"flowStatus\",\"type\":\"string\",\"description\":\"flowStatus\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"query\",\"name\":\"query\",\"type\":\"string\",\"description\":\"query\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"config\",\"name\":\"config\",\"type\":\"object\",\"description\":\"config\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"config.autoLayout\",\"name\":\"autoLayout\",\"type\":\"boolean\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false,\"description\":\"Wide card layout\"}]},{\"id\":\"brand\",\"name\":\"brand\",\"type\":\"markdown\",\"description\":\"brand\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"hasReasoning\",\"name\":\"hasReasoning\",\"type\":\"string\",\"description\":\"hasReasoning\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"autoLayout\",\"name\":\"autoLayout\",\"type\":\"string\",\"description\":\"Card auto layout\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false}],\"formList\":[],\"customContextList\":[],\"expList\":[],\"localList\":[],\"hsfList\":[],\"lwpList\":[],\"pageData\":{},\"extension\":{\"extendType\":\"AI\",\"aiStatusList\":[3,1,5,2,4],\"fileTypeList\":[]}}", + "widgetInfo": "", + "type": "im", + "mode": "card" +} diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js index 72e3f5355..6cd9643a4 100644 --- a/src/langbot/templates/embed/widget.js +++ b/src/langbot/templates/embed/widget.js @@ -174,6 +174,17 @@ bubbleIcon: "__LANGBOT_BUBBLE_ICON__", }; + // Wizard previews must use the browser-visible backend URL. Behind a TLS + // proxy the server's request URL can be HTTP even when widget.js uses HTTPS. + // External embeds retain their configured public webhook prefix. + if (scriptEl && scriptEl.src) { + var scriptUrl = new URL(scriptEl.src, document.baseURI); + if (scriptUrl.searchParams.get("preview") === "wizard") { + CONFIG.baseUrl = scriptUrl.origin; + CONFIG.logoUrl = CONFIG.baseUrl + "/api/v1/embed/logo"; + } + } + // ========== Styles ========== var STYLES = '\ @@ -524,18 +535,15 @@ return; } - var protocol = CONFIG.baseUrl.indexOf("https") === 0 ? "wss:" : "ws:"; - var host = CONFIG.baseUrl.replace(/^https?:\/\//, ""); - var url = - protocol + - "//" + - host + - "/api/v1/embed/" + - CONFIG.botUuid + - "/ws/connect?session_type=" + - CONFIG.sessionType + - "&session_id=" + - encodeURIComponent(state.sessionId); + var endpoint = new URL(CONFIG.baseUrl || "/", window.location.href); + endpoint.protocol = endpoint.protocol === "https:" || endpoint.protocol === "wss:" ? "wss:" : "ws:"; + endpoint.pathname = endpoint.pathname.replace(/\/$/, "") + + "/api/v1/embed/" + encodeURIComponent(CONFIG.botUuid) + "/ws/connect"; + endpoint.search = ""; + endpoint.hash = ""; + endpoint.searchParams.set("session_type", CONFIG.sessionType); + endpoint.searchParams.set("session_id", state.sessionId); + var url = endpoint.toString(); try { state.ws = new WebSocket(url); diff --git a/src/langbot/templates/metadata/pipeline/output.yaml b/src/langbot/templates/metadata/pipeline/output.yaml index d5e0fae07..d3acfc693 100644 --- a/src/langbot/templates/metadata/pipeline/output.yaml +++ b/src/langbot/templates/metadata/pipeline/output.yaml @@ -106,11 +106,81 @@ stages: en_US: Failure Hint Text zh_Hans: 失败提示文本 description: - en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint" - zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效 + en_US: Leave empty to use the Workspace language. Used for request and runner execution failures; custom text is not translated. + zh_Hans: 留空时使用工作区语言的默认提示。用于请求和运行器执行失败;自定义文本不会自动翻译。 type: string required: false - default: 'Request failed.' + default: '' + - name: rate-limit-hint + label: + en_US: Rate Limit Hint + zh_Hans: 限速提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: content-filter-hint + label: + en_US: Content Filter Hint + zh_Hans: 内容过滤提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: content-filter-error-hint + label: + en_US: Content Check Failure Hint + zh_Hans: 内容检查失败提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: tool-call-hint + label: + en_US: Tool Call Hint + zh_Hans: 工具调用提示 + description: + en_US: "Leave empty to use the Workspace language. Use {tools} for tool names. Requires Track Function Calls." + zh_Hans: "留空时使用工作区语言的默认提示。 可用 {tools} 插入工具名称,需开启跟踪函数调用。" + type: string + required: false + default: '' + - name: runner-not-found-hint + label: + en_US: Runner Missing Hint + zh_Hans: 运行器不存在提示 + description: + en_US: "Leave empty to use the Workspace language. Use {runner} for the runner ID." + zh_Hans: "留空时使用工作区语言的默认提示。 可用 {runner} 插入运行器 ID。" + type: string + required: false + default: '' + - name: runner-not-authorized-hint + label: + en_US: Runner Authorization Hint + zh_Hans: 运行器未授权提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' + - name: runner-unavailable-hint + label: + en_US: Runner Unavailable Hint + zh_Hans: 运行器不可用提示 + description: + en_US: "Leave empty to use the Workspace language." + zh_Hans: "留空时使用工作区语言的默认提示。" + type: string + required: false + default: '' - name: at-sender label: en_US: At Sender diff --git a/tests/README.md b/tests/README.md index 3a110b9dc..d87fc3d68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -160,6 +160,10 @@ The test suite uses a centralized fixture system that provides: 3. **Coverage**: Tests cover happy paths, edge cases, and error conditions 4. **Extensibility**: Easy to add new tests by reusing existing fixtures +See [provisioned dependency contracts](../docs/DEPENDENCY_CONTRACT_TESTS.md) for +the real compiled CLI, pinned official plugin identity helpers, and embedded +SeekDB CI jobs, including local prerequisites and verification boundaries. + ## Running Tests ### Quick self-test for developers @@ -291,6 +295,11 @@ Note: PostgreSQL tests are **not** included in fast integration gate because the - Are marked with `@pytest.mark.slow` - Need `TEST_POSTGRES_URL` environment variable +A separate **PostgreSQL Contract Tests** job provisions a disposable database for +pipeline admission, RAG recovery, migration convergence, and timestamp/lease contracts. +See [the PostgreSQL contract guide](../docs/POSTGRES_CONTRACT_TESTS.md) for scope, +local commands, and remaining optional prerequisites. + CI workflow `.github/workflows/test-migrations.yml` runs: - SQLite tests in `test-migrations-sqlite` job (fast, no external services) - PostgreSQL tests in `test-migrations-postgres` job (uses PostgreSQL service container) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..8e63694e3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +"""Repository-wide test execution policy.""" + +pytest_plugins = ['tests.utils.required_suites'] diff --git a/tests/e2e/live_aiocqhttp_eba_probe.py b/tests/e2e/live_aiocqhttp_eba_probe.py index 493d208e3..dc193c9bd 100644 --- a/tests/e2e/live_aiocqhttp_eba_probe.py +++ b/tests/e2e/live_aiocqhttp_eba_probe.py @@ -7,6 +7,7 @@ import json import time from collections import Counter from pathlib import Path +from types import SimpleNamespace from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger @@ -36,9 +37,8 @@ def dump_event(event: platform_events.Event) -> dict: async def main(): parser = argparse.ArgumentParser(description='Live OneBot v11 / aiocqhttp EBA probe for Matcha or a real endpoint.') - parser.add_argument('--host', default='127.0.0.1') - parser.add_argument('--port', type=int, default=2280) - parser.add_argument('--access-token', default='') + parser.add_argument('--ws-url', required=True, help='Protocol endpoint Universal WebSocket URL') + parser.add_argument('--access-token', required=True) parser.add_argument('--timeout', type=int, default=120) parser.add_argument('--target-type', choices=['private', 'group'], default=None) parser.add_argument('--target-id', default=None) @@ -54,10 +54,18 @@ async def main(): out_fp = out_path.open('a', encoding='utf-8') adapter = AiocqhttpAdapter( - {'host': args.host, 'port': args.port, 'access-token': args.access_token}, + {'connection_mode': 'forward_ws', 'ws_url': args.ws_url, 'access-token': args.access_token}, ProbeLogger(), ) + # This standalone converter probe uses a single explicit local runtime. + runtime = SimpleNamespace(enable=True, workspace_uuid='probe', bot_entity=SimpleNamespace(uuid='probe')) + + async def resolve_probe(_): + return runtime + + runtime.ap = SimpleNamespace(platform_mgr=SimpleNamespace(resolve_public_bot=resolve_probe)) + adapter.bind_runtime(runtime) observed: list[platform_events.Event] = [] first_message = asyncio.Event() @@ -79,7 +87,7 @@ async def main(): return None task = asyncio.create_task(adapter.run_async()) - print(f'Listening on ws://{args.host}:{args.port}/ws/ . Trigger events from Matcha now.') + print('Connecting to the protocol endpoint. Trigger events now.') api_results: dict[str, str] = {} try: diff --git a/tests/e2e/test_llm_request_chain.py b/tests/e2e/test_llm_request_chain.py new file mode 100644 index 000000000..fdfabd9be --- /dev/null +++ b/tests/e2e/test_llm_request_chain.py @@ -0,0 +1,442 @@ +"""Real HTTP -> Core -> Plugin Runtime -> Runner -> Core -> HTTP provider tests. + +Only the remote provider is deterministic. No requester, SDK action transport, +model manager, persistence or monitoring implementation is mocked. +""" + +import json +import importlib.metadata +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import shutil +import select +import socket +import sqlite3 +import threading +import time + +import pytest +import yaml +import httpx +from packaging.version import Version + +from tests.e2e import test_runner_plugin_runtime as runtime + +# Reuse the real process lifecycle; override the package/config and port below. +runner_runtime_ports = runtime.runner_runtime_ports +runner_runtime_process = runtime.runner_runtime_process +runner_langbot_process = runtime.runner_langbot_process +runner_client = runtime.runner_client + +pytestmark = pytest.mark.e2e +RUNNER = runtime.QA_RUNNER_ID +USAGE = { + 'prompt_tokens': 24, + 'completion_tokens': 4, + 'total_tokens': 28, + 'prompt_tokens_details': {'cached_tokens': 16}, + 'completion_tokens_details': {'reasoning_tokens': 2}, +} + + +@pytest.fixture(scope='session') +def runner_e2e_tmpdir(tmp_path_factory): + # Retain logs/SQLite in pytest's bounded temp history for failure diagnosis. + return tmp_path_factory.mktemp('llm-chain') + + +@pytest.fixture(scope='session') +def runner_e2e_port(): + return runtime._free_port() + + +@pytest.fixture(scope='session') +def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports): + path = runtime.runner_e2e_config_path.__wrapped__(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports) + root = runner_e2e_tmpdir / 'agent-runner-qa-package' + manifest = root / 'components/runner/default.yaml' + data = yaml.safe_load(manifest.read_text(encoding='utf-8')) + data['spec']['permissions'] = {'models': ['invoke', 'stream', 'count_tokens']} + data['spec']['config'] = [ + {'name': 'model', 'label': {'en_US': 'Model'}, 'type': 'llm-model-selector', 'required': True}, + ] + manifest.write_text(yaml.safe_dump(data), encoding='utf-8') + shutil.copyfile(Path(__file__).parent / 'utils/llm_probe_runner.py', root / 'components/runner/default.py') + if legacy_root := os.environ.get('LLM_E2E_LEGACY_API_DIR'): + for filename in ('legacy_api.py', 'legacy_message.py'): + shutil.copyfile(Path(legacy_root) / filename, root / filename) + shutil.make_archive(str(runner_e2e_tmpdir / 'agent-runner-qa'), 'zip', root_dir=root) + return path + + +@pytest.fixture(scope='module') +def provider_wire(): + records = [] + + class Handler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def log_message(self, *args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + scenario = self.headers.get('x-e2e-case', 'normal') + row = {'body': body, 'headers': dict(self.headers), 'case': scenario, 'closed': False} + records.append(row) + attempt = sum(x['case'] == scenario for x in records) + status = 503 if scenario.startswith('retry') and attempt == 1 else 200 + if scenario == 'auth': + status = 401 + if status != 200: + data = json.dumps({'error': {'message': 'injected failure', 'type': 'api_error'}}).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(data))) + self.send_header('Retry-After', '0') + self.end_headers() + self.wfile.write(data) + return + if scenario in ('first-timeout', 'total-timeout'): + time.sleep(1) + try: + if not body.get('stream'): + data = json.dumps( + { + 'id': 'e2e', + 'object': 'chat.completion', + 'created': 1, + 'model': body['model'], + 'choices': [ + { + 'index': 0, + 'message': {'role': 'assistant', 'content': 'hello world'}, + 'finish_reason': 'stop', + } + ], + 'usage': USAGE, + } + ).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + return + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Transfer-Encoding', 'chunked') + self.end_headers() + + def send(event): + data = ('data: ' + (event if isinstance(event, str) else json.dumps(event)) + '\n\n').encode() + self.wfile.write(f'{len(data):X}\r\n'.encode() + data + b'\r\n') + self.wfile.flush() + + base = {'id': 'e2e', 'object': 'chat.completion.chunk', 'created': 1, 'model': body['model']} + if scenario.startswith('reasoning-'): + send( + { + **base, + 'choices': [ + {'index': 0, 'delta': {'reasoning_content': 'private thought'}, 'finish_reason': None} + ], + } + ) + for index, content in enumerate(['hello ', 'world']): + send({**base, 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]}) + if index == 0 and (scenario in ('idle-timeout', 'disconnect') or scenario.startswith('cancel-')): + if scenario == 'disconnect': + self.close_connection = True + return + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + if select.select([self.connection], [], [], 0.02)[0]: + if not self.connection.recv(1, socket.MSG_PEEK): + row['closed'] = True + return + send({**base, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]}) + send({**base, 'choices': [], 'usage': USAGE}) + send('[DONE]') + self.wfile.write(b'0\r\n\r\n') + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + row['closed'] = True + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f'http://127.0.0.1:{server.server_port}/v1', records + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture(scope='module') +def chain(request, provider_wire): + request.getfixturevalue('runner_langbot_process') + port = request.getfixturevalue('runner_e2e_port') + client = httpx.Client(base_url=f'http://127.0.0.1:{port}', timeout=90, trust_env=False) + request.addfinalizer(client.close) + tmpdir = request.getfixturevalue('runner_e2e_tmpdir') + token = runtime._init_and_auth(client) + runtime._ensure_qa_plugin(client, token, tmpdir / 'agent-runner-qa.zip') + client.headers['Authorization'] = f'Bearer {token}' + + def post(path, body): + response = client.post('/api/v1/' + path, json=body) + assert response.status_code == 200, response.text + payload = response.json() + assert payload['code'] == 0, payload + return payload['data'] + + provider = post( + 'provider/providers', + { + 'name': 'LLM chain E2E', + 'requester': 'openai-chat-completions', + 'base_url': provider_wire[0], + 'api_keys': ['e2e-not-a-secret'], + }, + )['uuid'] + model = post( + 'provider/models/llm', + { + 'name': 'gpt-4o', + 'provider_uuid': provider, + 'abilities': ['func_call', 'vision'], + 'extra_args': { + 'temperature': 0.1, + 'request_policy': {'retries': 1, 'backoff_base': 0.01, 'backoff_max': 0.01}, + }, + }, + )['uuid'] + agent = post( + 'agents', + { + 'kind': 'agent', + 'name': 'LLM chain E2E', + 'config': { + 'runner': {'id': RUNNER}, + 'runner_config': {RUNNER: {'model': model}}, + }, + }, + )['uuid'] + + def invoke(mode, case='normal', extra=None, *, configuration=None, options=None): + selected_model, selected_agent = model, agent + if configuration: + configured_provider = post( + 'provider/providers', + { + 'name': case, + 'requester': configuration.get('requester', 'openai-chat-completions'), + 'base_url': provider_wire[0], + 'api_keys': ['e2e-not-a-secret'], + }, + )['uuid'] + selected_model = post( + 'provider/models/llm', + { + 'name': configuration.get('name', 'gpt-4o'), + 'provider_uuid': configured_provider, + 'abilities': ['func_call', 'vision'], + 'extra_args': configuration.get('extra_args', {}), + }, + )['uuid'] + selected_agent = post( + 'agents', + { + 'kind': 'agent', + 'name': case, + 'config': { + 'runner': {'id': RUNNER}, + 'runner_config': {RUNNER: {'model': selected_model}}, + }, + }, + )['uuid'] + result = post( + f'agents/{selected_agent}/debug', + { + 'text': json.dumps( + {'mode': mode, 'model': selected_model, 'case': case, 'extra': extra or {}, **(options or {})} + ), + 'conversation_id': 'e2e-' + case, + 'event_type': 'message.received', + }, + ) + assert result.get('final_text'), result + return json.loads(result['final_text']) + + return invoke, provider_wire[1], tmpdir + + +def test_multimodal_and_tool_definitions_survive_plugin_transport(chain): + invoke, records, _ = chain + image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' + messages = [ + { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'Describe this image'}, + {'type': 'image_url', 'image_url': {'url': image}}, + ], + } + ] + result = invoke('runner-events', 'multimodal', options={'messages': messages, 'tool': True}) + assert result['text'] == 'hello world', result + wire = records[-1]['body'] + assert wire['messages'][0]['content'][1]['image_url']['url'] == image + assert wire['tools'][0]['function']['name'] == 'e2e_lookup' + + +def test_openrouter_budget_cache_and_explicit_attribution(chain): + invoke, records, _ = chain + result = invoke( + 'runner-events', + 'openrouter-budget-cache', + configuration={ + 'requester': 'openrouter-chat-completions', + 'name': 'anthropic/claude-sonnet-4', + 'extra_args': { + 'reasoning_config': {'budget_tokens': 2048}, + 'prompt_caching': {'mode': 'anthropic'}, + 'max_tokens': 4096, + }, + }, + extra={ + 'extra_headers': {'HTTP-Referer': 'https://langbot.app', 'X-OpenRouter-Title': 'LangBot'}, + 'request_policy': {'conversation_header': 'x-langbot-conversation-id'}, + }, + options={'messages': [{'role': 'system', 'content': 'Stable prefix'}, {'role': 'user', 'content': 'Hello'}]}, + ) + assert result['text'] == 'hello world', result + wire = records[-1] + assert wire['body']['reasoning']['max_tokens'] == 2048 + assert wire['body']['messages'][0]['content'][0]['cache_control']['type'] == 'ephemeral' + headers = {k.lower(): v for k, v in wire['headers'].items()} + assert headers['http-referer'] == 'https://langbot.app' + assert headers['x-openrouter-title'] == 'LangBot' + assert headers['x-langbot-conversation-id'].startswith('lb-') + assert 'prompt_caching' not in wire['body'] and 'reasoning_config' not in wire['body'] + + +def test_openai_cache_options_and_call_override(chain): + invoke, records, _ = chain + result = invoke( + 'runner-usage', + 'openai-cache', + configuration={ + 'extra_args': {'temperature': 0.1, 'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}}, + }, + extra={'temperature': 0.7, 'prompt_cache_key': 'explicit-test-cache'}, + ) + assert result['text'] == 'hello world', result + assert records[-1]['body']['temperature'] == 0.7 + assert records[-1]['body']['prompt_cache_key'] == 'explicit-test-cache' + assert records[-1]['body']['prompt_cache_retention'] == 'in_memory' + + +@pytest.mark.parametrize( + 'mode', + [ + 'runner-message', + 'runner-usage', + 'runner-stream', + 'runner-events', + 'plugin-message', + 'plugin-usage', + 'plugin-stream', + 'plugin-events', + ], +) +def test_public_api_shapes_and_usage(chain, mode): + invoke, records, _ = chain + result = invoke(mode, mode) + assert result['text'] == 'hello world', result + assert not result.get('error'), result + if mode.endswith(('usage', 'events')): + assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16 + assert result['usage']['completion_tokens_details']['reasoning_tokens'] == 2 + wire = records[-1]['body'] + assert wire['temperature'] == 0.1 + assert 'request_policy' not in wire + + +@pytest.mark.parametrize( + 'case,extra,attempts,error', + [ + ('retry-once', {}, 2, False), + ('retry-disabled', {'num_retries': 0}, 1, True), + ('retry-max-disabled', {'max_retries': 0}, 1, True), + ('auth', {}, 1, True), + ('first-timeout', {'request_policy': {'first_event_timeout': 0.2, 'retries': 0}}, 1, True), + ('total-timeout', {'request_policy': {'total_timeout': 0.2, 'retries': 0}}, 1, True), + ('idle-timeout', {'request_policy': {'stream_idle_timeout': 0.2}}, 1, True), + ('disconnect', {}, 1, True), + ], +) +def test_failures_retry_boundaries_and_recovery(chain, case, extra, attempts, error): + invoke, records, _ = chain + result = invoke('runner-events', case, extra) + assert bool(result.get('error')) == error, result + assert sum(row['case'] == case for row in records) == attempts + assert invoke('runner-events', 'recover-' + case)['text'] == 'hello world' + + +@pytest.mark.parametrize('surface', ['runner-events', 'runner-stream', 'plugin-events', 'plugin-stream']) +def test_cancellation_releases_upstream_and_next_request_works(chain, surface): + invoke, records, tmpdir = chain + case = 'cancel-' + surface + result = invoke(case, case) + assert result['text'] == 'hello ', result + time.sleep(1.5) + cancelled = [row for row in records if row['case'] == case] + with sqlite3.connect(tmpdir / 'data/langbot.db') as db: + diagnostics = db.execute('SELECT session_id, details FROM monitoring_llm_calls').fetchall() + assert len(cancelled) == 1 + assert invoke('runner-events', 'after-' + case)['text'] == 'hello world' + if ( + not cancelled[0]['closed'] + and Version(importlib.metadata.version('langbot-plugin')) <= Version('0.7.11') + and not os.environ.get('LLM_E2E_REQUIRE_STREAM_CLOSE') + ): + pytest.xfail('Published SDK <=0.7.11 omits aclosing in public proxy wrappers; requires companion SDK fix') + assert cancelled[0]['closed'], diagnostics[-2:] + + +@pytest.mark.parametrize('remove_think', [True, False]) +def test_reasoning_visibility_through_runner(chain, remove_think): + result = chain[0]('runner-events', f'reasoning-{remove_think}', options={'remove_think': remove_think}) + assert result['text'].endswith('hello world'), result + assert ('private thought' in result['text']) == (not remove_think) + + +def test_invalid_budget_fails_before_network_and_recovers(chain): + invoke, records, _ = chain + result = invoke('runner-events', 'invalid-budget', {'reasoning_config': {'budget_tokens': 1}}) + assert result.get('error'), result + assert not any(row['case'] == 'invalid-budget' for row in records) + assert invoke('runner-events', 'after-invalid-budget')['text'] == 'hello world' + + +def test_monitoring_persists_complete_usage_and_structured_errors(chain): + invoke, _, tmpdir = chain + invoke('runner-events', 'monitoring') + with sqlite3.connect(tmpdir / 'data/langbot.db') as db: + rows = db.execute('SELECT details FROM monitoring_llm_calls').fetchall() + details = [json.loads(row[0]) for row in rows if row[0]] + assert any(x.get('usage', {}).get('prompt_tokens_details', {}).get('cached_tokens') == 16 for x in details) + assert any(x.get('timing', {}).get('transport_setup_ms') is not None for x in details) + assert any(x.get('error', {}).get('code') for x in details if x.get('error')) + + +@pytest.mark.skipif(not os.environ.get('LLM_E2E_LEGACY_API_DIR'), reason='Historical SDK sources not supplied') +@pytest.mark.parametrize('mode', ['legacy-message', 'legacy-usage', 'legacy-stream', 'legacy-events']) +def test_historical_sdk_proxy_and_message_entities(chain, mode): + result = chain[0](mode, mode) + assert result['text'] == 'hello world', result + assert not result.get('error'), result + if mode.endswith(('usage', 'events')): + assert result['usage']['prompt_tokens_details']['cached_tokens'] == 16 diff --git a/tests/e2e/test_local_runner_fake_provider.py b/tests/e2e/test_local_runner_fake_provider.py index 37a4b9044..cbb953bad 100644 --- a/tests/e2e/test_local_runner_fake_provider.py +++ b/tests/e2e/test_local_runner_fake_provider.py @@ -1,7 +1,7 @@ """E2E coverage for the official Local Agent runner with fake Host resources. These tests start the real LangBot application and the real SDK Plugin Runtime, -load the consolidated ``langbot-plugin-demo/Runner/LocalAgent`` plugin, and verify Local Agent paths +load the pinned official ``langbot-plugins/Runner/LocalAgent`` plugin, and verify Local Agent paths that must cross Host run-scoped APIs without calling any external provider. """ @@ -13,6 +13,7 @@ import os import shutil import socket import sqlite3 +import stat import subprocess import sys import tempfile @@ -23,9 +24,9 @@ from typing import Any import pytest from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories -from tests.e2e.utils.process_manager import find_project_root +from tests.utils.legacy_plugin_contract import verified_plugin_root -pytestmark = pytest.mark.e2e +pytestmark = [pytest.mark.e2e, pytest.mark.local_agent] LOCAL_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default' @@ -43,16 +44,15 @@ def _free_port() -> int: def _local_agent_repo() -> Path: - """Return the sibling local-agent repository used by this workspace E2E.""" - project_root = find_project_root() - return project_root.parent / 'langbot-plugin-demo' / 'Runner' / 'LocalAgent' + """Use the same official source pin as the plugin identity contracts.""" + return verified_plugin_root(files_key='local_agent_files') / 'Runner' / 'LocalAgent' def _package_local_agent_plugin(tmpdir: Path) -> Path: - """Package the sibling Local Agent plugin for the real local-install flow.""" + """Package the verified official plugin for the real local-install flow.""" local_agent_src = _local_agent_repo() if not (local_agent_src / 'manifest.yaml').exists(): - pytest.skip(f'local-agent repository not found at {local_agent_src}') + pytest.fail(f'LocalAgent manifest not found at {local_agent_src}', pytrace=False) package_source = tmpdir / 'local-agent-package' ignore = shutil.ignore_patterns( @@ -275,7 +275,12 @@ def local_agent_e2e_tmpdir(): """Create temporary directory for Local Agent E2E testing.""" tmpdir = Path(tempfile.mkdtemp(prefix='langbot_local_agent_e2e_')) yield tmpdir - shutil.rmtree(tmpdir, ignore_errors=True) + # The runtime seals its artifact/dependency directories as read-only. + # Restore only this fixture's own directories so teardown removes them. + for path in tmpdir.rglob('*'): + if path.is_dir() and not path.is_symlink(): + path.chmod(path.stat().st_mode | stat.S_IWUSR) + shutil.rmtree(tmpdir) @pytest.fixture(scope='session') @@ -552,6 +557,7 @@ def _run_local_agent_probe(tmpdir: Path, probe): return asyncio.run(_run()) +@pytest.mark.contract('agent_ledger') def test_local_runner_uses_host_fake_provider_and_persists_ledger( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -614,6 +620,7 @@ def test_local_runner_uses_host_fake_provider_and_persists_ledger( conn.close() +@pytest.mark.contract('agent_tools') def test_local_runner_executes_authorized_tool_loop_through_host_action( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -683,6 +690,7 @@ def test_local_runner_executes_authorized_tool_loop_through_host_action( conn.close() +@pytest.mark.contract('agent_rag') def test_local_runner_retrieves_authorized_rag_context_through_host_action( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -757,6 +765,7 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action( conn.close() +@pytest.mark.contract('agent_compaction') def test_local_runner_compacts_history_and_persists_checkpoint( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -850,6 +859,7 @@ def test_local_runner_compacts_history_and_persists_checkpoint( conn.close() +@pytest.mark.contract('agent_multi_turn') def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -1012,6 +1022,7 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop( conn.close() +@pytest.mark.contract('agent_box') def test_local_runner_owns_box_reuse_and_explicit_files( local_agent_e2e_tmpdir, local_agent_e2e_config_path, @@ -1050,6 +1061,7 @@ def test_local_runner_owns_box_reuse_and_explicit_files( super().__init__() self.box = box self.bindings = [] + self.outboxes = [] async def get_resolved_tool_catalog(self, *args, **kwargs): return [{'name': 'exec', 'source': 'native', 'source_id': None}] @@ -1066,19 +1078,31 @@ def test_local_runner_owns_box_reuse_and_explicit_files( self.bindings.append((binding.session_id, binding.run_id)) source = next(iter(binding.imported.values()))['path'] outbox = f'/workspace/outbox/{binding.io_scope}' + self.outboxes.append(outbox) # Shell arguments are Host-issued paths, never model input. import shlex + directory_mode = '-m 700 ' if len(self.bindings) == 2 else '' result = await self.box.execute_tool( { - 'command': f'mkdir -p {shlex.quote(outbox)} && cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")}' + 'command': ( + f'mkdir {directory_mode}-p {shlex.quote(outbox)} && ' + f'cp {shlex.quote(source)} {shlex.quote(outbox + "/answer.txt")} && ' + f"stat -c '%u %a' {shlex.quote(outbox)} {shlex.quote(outbox + '/answer.txt')}" + ) }, query, ) assert result['ok'], result + ownership = result['stdout'].strip().splitlines() + assert ownership[-1] == '0 600', ownership + if len(self.bindings) == 2: + assert ownership[0] == '0 700', ownership return result async def probe(ap): + import shlex + backend = TestDockerBackend(ap.logger) if not await backend.is_available(): pytest.skip('Docker is required for the real Box probe') @@ -1120,6 +1144,12 @@ def test_local_runner_owns_box_reuse_and_explicit_files( output = [component for message in messages for component in (message.attachments or [])] assert len(output) == 1, messages assert base64.b64decode(output[0].base64) == f'run-{index}'.encode() + inspection = await box.execute_in_context( + context, + {'cmd': f'ls -A {shlex.quote(manager.outboxes[-1])}', 'session_id': manager.bindings[-1][0]}, + ) + assert inspection.exit_code == 0, inspection + assert inspection.stdout.strip() == '', inspection.stdout status = await RunnerBoxService(box).status(context) assert status['used'] == 1 and status['remaining'] == 0, status assert manager.bindings[0][0] == manager.bindings[1][0] @@ -1129,6 +1159,18 @@ def test_local_runner_owns_box_reuse_and_explicit_files( with pytest.raises(BoxCapacityExceededError): await RunnerBoxService(box).acquire(context, {'reuse_key': 'different'}) finally: - await box.shutdown() + try: + if manager.bindings: + # Only this fixture's temporary Workspace is mounted here. + cleanup = await box.execute_in_context( + context, + { + 'cmd': f'chown -hR {os.getuid()}:{os.getgid()} /workspace/outbox', + 'session_id': manager.bindings[-1][0], + }, + ) + assert cleanup.exit_code == 0, cleanup + finally: + await box.shutdown() _run_local_agent_probe(local_agent_e2e_tmpdir, probe) diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py index 955e33af6..939effc6b 100644 --- a/tests/e2e/test_startup.py +++ b/tests/e2e/test_startup.py @@ -16,6 +16,75 @@ import pytest pytestmark = pytest.mark.e2e +@pytest.fixture(scope='session') +def e2e_tmpdir(tmp_path_factory): + # Initial registration is global; keep this module independent of other E2E accounts. + return tmp_path_factory.mktemp('langbot_startup') + + +@pytest.fixture(scope='session') +def e2e_port(): + import socket + + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + return listener.getsockname()[1] + + +@pytest.fixture(scope='session') +def langbot_process(e2e_tmpdir, e2e_port): + from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories + from tests.e2e.utils.process_manager import LangBotProcess, find_project_root + + create_minimal_config(e2e_tmpdir, port=e2e_port) + create_test_directories(e2e_tmpdir) + process = LangBotProcess( + project_root=find_project_root(), + work_dir=e2e_tmpdir, + port=e2e_port, + timeout=180, + collect_coverage=True, + ) + try: + assert process.start(), process.get_logs() + yield process + finally: + process.stop() + coverage_file = process.get_coverage_file() + if coverage_file is not None and coverage_file.exists(): + import shutil + + shutil.copy(coverage_file, find_project_root() / '.coverage.e2e.startup') + + +@pytest.fixture(scope='session') +def e2e_db_path(e2e_tmpdir): + return e2e_tmpdir / 'data/langbot.db' + + +@pytest.fixture +def startup_credentials(e2e_client): + credentials = {'user': 'startup-e2e@example.com', 'password': 'startup-local-test-password'} + state = e2e_client.get('/api/v1/user/init') + assert state.status_code == 200 + if not state.json()['data']['initialized']: + response = e2e_client.post('/api/v1/user/init', json=credentials) + assert response.status_code == 200, response.text + assert response.json()['code'] == 0 + return credentials + + +@pytest.fixture +def startup_headers(e2e_client, startup_credentials): + response = e2e_client.post('/api/v1/user/auth', json=startup_credentials) + assert response.status_code == 200, response.text + headers = {'Authorization': f'Bearer {response.json()["data"]["token"]}'} + bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers) + assert bootstrap.status_code == 200, bootstrap.text + headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid'] + return headers + + class TestStartupFlow: """Tests for LangBot startup process.""" @@ -87,22 +156,21 @@ class TestStartupFlow: response = e2e_client.get('/api/v1/pipelines') assert response.status_code == 401 - 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={ - 'user': 'admin', - 'password': 'admin', - }, - ) + def test_auth_endpoint(self, e2e_client, startup_credentials): + """A controlled local account can sign in and use its token.""" + response = e2e_client.post('/api/v1/user/auth', json=startup_credentials) + assert response.status_code == 200, response.text + assert response.json()['code'] == 0 + token = response.json()['data']['token'] + assert isinstance(token, str) and token + bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers={'Authorization': f'Bearer {token}'}) + assert bootstrap.status_code == 200, bootstrap.text + assert bootstrap.json()['data']['workspaces'][0]['membership']['role'] == 'owner' - # Response could be: - # - 200 if auth succeeds - # - 400 if credentials wrong - # - 401 if user not initialized - assert response.status_code in [200, 400, 401] + rejected = e2e_client.post('/api/v1/user/auth', json={**startup_credentials, 'password': 'wrong-password'}) + assert rejected.status_code == 200, rejected.text + assert rejected.json()['code'] == 1 + assert rejected.json().get('data', {}).get('token') is None class TestStartupStages: @@ -127,24 +195,37 @@ class TestStartupStages: cursor.execute('SELECT version_num FROM alembic_version;') version = cursor.fetchone() - assert version is not None, 'Migration version should be set' + from pathlib import Path + + from alembic.script import ScriptDirectory + + migrations = Path(__file__).parents[2] / 'src/langbot/pkg/persistence/alembic' + assert version == (ScriptDirectory(str(migrations)).get_current_head(),) conn.close() - def test_http_controller_initialized(self, e2e_client): - """Verify HTTP controller was initialized.""" - # Multiple endpoints should be available - endpoints = [ - '/api/v1/system/info', - '/api/v1/pipelines', - '/api/v1/provider/providers', - '/api/v1/platform/bots', - ] + def test_http_controller_initialized(self, e2e_client, startup_headers): + """Authenticated business endpoints return their documented collections.""" + info = e2e_client.get('/api/v1/system/info', headers=startup_headers) + assert info.status_code == 200 + assert isinstance(info.json()['data']['version'], str) - for endpoint in endpoints: - response = e2e_client.get(endpoint) - # Should get a real route response, even if auth is required. - assert response.status_code in [200, 401, 403], f'{endpoint} should be registered' + for endpoint, key in [ + ('/api/v1/pipelines', 'pipelines'), + ('/api/v1/provider/providers', 'providers'), + ('/api/v1/platform/bots', 'bots'), + ]: + response = e2e_client.get(endpoint, headers=startup_headers) + assert response.status_code == 200, (endpoint, response.text) + payload = response.json() + assert payload['code'] == 0 + assert isinstance(payload['data'][key], list) + if key == 'providers': + assert [(item['name'], item['requester'], item['api_keys']) for item in payload['data'][key]] == [ + ('LangBot Models', 'space-chat-completions', []) + ] + else: + assert payload['data'][key] == [] class TestMinimalStartupNoLLM: @@ -155,8 +236,14 @@ class TestMinimalStartupNoLLM: response = e2e_client.get('/api/v1/system/info') assert response.status_code == 200 - def test_pipeline_metadata_available(self, e2e_client): - """Pipeline metadata endpoint should work without LLM.""" - # Requires auth, but endpoint should exist - response = e2e_client.get('/api/v1/pipelines/_/metadata') - assert response.status_code in [200, 401] # Not 404 or 500 + def test_pipeline_metadata_available(self, e2e_client, startup_headers): + """All pipeline configuration groups remain readable without an LLM.""" + response = e2e_client.get('/api/v1/pipelines/_/metadata', headers=startup_headers) + assert response.status_code == 200, response.text + payload = response.json() + assert payload['code'] == 0 + configs = payload['data']['configs'] + assert [config['name'] for config in configs] == ['trigger', 'safety', 'ai', 'output'] + ai = configs[2] + runner = next(stage for stage in ai['stages'] if stage['name'] == 'runner') + assert any(item['name'] == 'id' for item in runner['config']) diff --git a/tests/e2e/utils/llm_probe_runner.py b/tests/e2e/utils/llm_probe_runner.py new file mode 100644 index 000000000..9b1115408 --- /dev/null +++ b/tests/e2e/utils/llm_probe_runner.py @@ -0,0 +1,93 @@ +"""Packaged into the isolated E2E plugin; calls only public SDK LLM APIs.""" + +from contextlib import aclosing +import json + +from langbot_plugin.api.definition.components.runner.runner import Runner +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.runner.result import RunnerResult +from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy + + +class DefaultRunner(Runner): + async def run(self, ctx): + options = json.loads(ctx.input.to_text()) + mode = options['mode'] + cancel = mode.startswith('cancel-') + if cancel: + mode = mode.removeprefix('cancel-') + api = LangBotAPIProxy(self._plugin_runtime_handler) if mode.startswith('plugin-') else ctx.api + message_type = Message + if mode.startswith('legacy-'): + # Historical files are supplied verbatim from a released SDK tag. + # Its proxy and serializers use the real current runtime transport. + import legacy_api + import legacy_message + + legacy_api.provider_message = legacy_message + api = legacy_api.LangBotAPIProxy(self._plugin_runtime_handler) + message_type = legacy_message.Message + extra = { + **options['extra'], + 'extra_headers': { + **options['extra'].get('extra_headers', {}), + 'x-e2e-case': options['case'], + }, + } + kwargs = { + 'llm_model_uuid': options['model'], + 'messages': [ + message_type.model_validate(m) + for m in options.get( + 'messages', + [ + {'role': 'user', 'content': 'Hello'}, + ], + ) + ], + 'extra_args': extra, + } + if options.get('tool'): + from langbot_plugin.api.entities.builtin.resource.tool import LLMTool + + async def lookup(**kwargs): + return 'result' + + kwargs['funcs'] = [ + LLMTool( + name='e2e_lookup', + human_desc='Lookup', + description='Lookup', + parameters={'type': 'object', 'properties': {}}, + func=lookup, + ) + ] + result = {'text': '', 'usage': None} + if 'remove_think' in options: + kwargs['remove_think'] = options['remove_think'] + try: + if mode.endswith('message'): + message = await api.invoke_llm(**kwargs) + assert isinstance(message, message_type) + result['text'] = message.content + elif mode.endswith('usage'): + response = await api.invoke_llm_with_usage(**kwargs) + result.update( + text=response.message.content, usage=response.usage.model_dump() if response.usage else None + ) + else: + events = mode.endswith('events') + method = api.invoke_llm_stream_events if events else api.invoke_llm_stream + async with aclosing(method(**kwargs)) as stream: + async for item in stream: + chunk = item.chunk if events else item + if chunk: + result['text'] += chunk.content or '' + if events and item.usage: + result['usage'] = item.usage.model_dump() + if cancel and result['text']: + break + except Exception as exc: + result['error'] = type(exc).__name__ + ': ' + str(exc) + yield RunnerResult.message_completed(ctx.run_id, Message(role='assistant', content=json.dumps(result))) + yield RunnerResult.run_completed(ctx.run_id, finish_reason='stop') diff --git a/tests/fixtures/legacy_plugin_identity_sources.json b/tests/fixtures/legacy_plugin_identity_sources.json new file mode 100644 index 000000000..62c60b864 --- /dev/null +++ b/tests/fixtures/legacy_plugin_identity_sources.json @@ -0,0 +1,41 @@ +{ + "repository": "langbot-app/langbot-plugins", + "revision": "7ad54b1a139d88113bc55128326800ed29779737", + "contract": "Current dedicated-workspace and trusted shared-installation identity; see each plugin SHARED_RUNTIME.md.", + "files": { + "Runner/dify-agent/components/runner/default.py": "0bcaff74c64c89be2bddb4be5871ce148a814676b50987a65f95228ecb7da8fa", + "Runner/dify-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a", + "Runner/dify-agent/pkg/dify_client.py": "607ed8d610d916e5b64ed20699e3de6e2216b238a5fecc9cabcdb75533b8dc56", + "Runner/n8n-agent/components/runner/default.py": "2719ec0a8ff779e90e61bd0209653fb9a1eab4b552877cfcbd4d3c9ce803fcf1", + "Runner/n8n-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a", + "Runner/n8n-agent/pkg/n8n_client.py": "15149b2d4162b51a3a7427d639908130ea4179014067f04a459a8a71ca105c15", + "Runner/coze-agent/components/runner/default.py": "23f222bd1bf94682d44df021d56e6e69801fbe8ea5bac99693d0f1e2bce8f5e8", + "Runner/coze-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a", + "Runner/coze-agent/pkg/coze_client.py": "379cb7f07f9931cc9de4cc40f8ebddea171c2a05383e662f6bd88ecdc5b3c317", + "Runner/tbox-agent/components/runner/default.py": "4c9fdf8cc10c916915036112ded8768a619a827ca4b5e6a3f05881a7d0e4492f", + "Runner/tbox-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a", + "Runner/tbox-agent/pkg/tbox_client.py": "0f09a3c70f773bc6377fd5b55a41d186dd15487d46aab3e61b837c08e15100a0" + }, + "local_agent_files": { + "Runner/LocalAgent/assets/icon.svg": "7e1cb51793e898fca810ec72ba6fe9af377074f52139cdd56c93e87f3459f964", + "Runner/LocalAgent/components/runner/default.py": "c63dc2697c61184e496d2fbed9808ccac23fb706a7dcccf34292da2b8e0079b9", + "Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47", + "Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f", + "Runner/LocalAgent/manifest.yaml": "e71e03f2670dade9ae43e58ddf787bb87254ba9f05e1a691822679f3641bb885", + "Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec", + "Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413", + "Runner/LocalAgent/pkg/agent_core/langbot.py": "dc16818ac81dacf37dd6ee799dec4fcd8235ffab4602ede30d29633e78626fc0", + "Runner/LocalAgent/pkg/agent_core/loop.py": "47451d5935d79b0a632fc87f3d94a7d0ecf1b50621141ea8b1031dd87755d5ad", + "Runner/LocalAgent/pkg/agent_core/types.py": "df82b1a17cd2b73f65ebbde22ab8f204ffd27b8d68814b404a1ecce9988e1fcf", + "Runner/LocalAgent/pkg/box.py": "82c05a55bd59b41e844f4613563e518768921fe4fd34bcb4c14c86e395ccd021", + "Runner/LocalAgent/pkg/config.py": "8dee31aecbcdba4c1e79e1735499c3c7ee826ec395032b045169652b38216faf", + "Runner/LocalAgent/pkg/context_pipeline.py": "4c6da3c48faf28985b8bad013fe15106705cd3e025f0c55708b463e4ea07eb05", + "Runner/LocalAgent/pkg/messages.py": "620a0a225e95c5f2c8c6e977801413e553d4a1da3fb2ddea14e527ad41f7a3af", + "Runner/LocalAgent/pkg/model_calling.py": "29989b102c8f649e1e65c7c5719761ed39d357d37d57bfa321976d48fb11b76c", + "Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f", + "Runner/LocalAgent/pkg/run_assembly.py": "ab08c6a18a108cfc6048f158b09ad874577451072dce0bbaa3aa9d15c33a3afe", + "Runner/LocalAgent/pyproject.toml": "ef14f2ed5d4d78591d2f1c0ac0f05856d558dae71da330a203abf3160356e6b1", + "Runner/LocalAgent/requirements.txt": "dd4542c179fa98c8341e7a0b582eb741a70633cd621f4509d09a9ff5c7e52c76", + "Runner/LocalAgent/uv.lock": "4c3c7655ef8c3984e7b720dea346ea67b4d29e8c6d543037779008c3bca074a8" + } +} diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py index 3e3a3a871..f74eb9abe 100644 --- a/tests/integration/api/test_bots.py +++ b/tests/integration/api/test_bots.py @@ -125,7 +125,7 @@ def fake_bot_app(): 'webhook_url': 'https://example.com/webhook/test-bot-uuid', } ) - app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'}) + app.bot_service.create_bot = AsyncMock(return_value='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)) @@ -179,6 +179,11 @@ def fake_bot_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_bot_app): + fake_bot_app.bot_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_bot_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -201,11 +206,17 @@ class TestBotEndpoints: assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 - assert 'data' in data - assert 'bots' in data['data'] + assert data['data']['bots'] == [ + { + 'uuid': 'test-bot-uuid', + 'name': 'Test Bot', + 'platform': 'telegram', + 'pipeline_uuid': 'test-pipeline-uuid', + } + ] @pytest.mark.asyncio - async def test_create_bot_success(self, quart_test_client): + async def test_create_bot_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots creates new bot.""" response = await quart_test_client.post( '/api/v1/platform/bots', @@ -218,8 +229,17 @@ class TestBotEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_bot_app.bot_service.create_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ( + {'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'}, + ) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-bot-uuid'} + @pytest.mark.asyncio - async def test_get_single_bot_success(self, quart_test_client): + async def test_get_single_bot_success(self, quart_test_client, fake_bot_app): """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'} @@ -230,8 +250,21 @@ class TestBotEndpoints: assert data['code'] == 0 assert 'bot' in data['data'] + called = fake_bot_app.bot_service.get_runtime_bot_info + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['bot'] == { + 'uuid': 'test-bot-uuid', + 'name': 'Test Bot', + 'platform': 'telegram', + 'pipeline_uuid': 'test-pipeline-uuid', + 'webhook_url': 'https://example.com/webhook/test-bot-uuid', + } + @pytest.mark.asyncio - async def test_update_bot_success(self, quart_test_client): + async def test_update_bot_success(self, quart_test_client, fake_bot_app): """PUT /api/v1/platform/bots/{uuid} updates bot.""" response = await quart_test_client.put( '/api/v1/platform/bots/test-bot-uuid', @@ -243,8 +276,14 @@ class TestBotEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_bot_app.bot_service.update_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_bot_success(self, quart_test_client): + async def test_delete_bot_success(self, quart_test_client, fake_bot_app): """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'} @@ -252,13 +291,20 @@ class TestBotEndpoints: assert response.status_code == 200 + called = fake_bot_app.bot_service.delete_bot + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestBotLogsEndpoint: """Tests for bot logs endpoint.""" @pytest.mark.asyncio - async def test_get_bot_logs_success(self, quart_test_client): + async def test_get_bot_logs_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots/{uuid}/logs returns logs.""" response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/logs', @@ -272,6 +318,13 @@ class TestBotLogsEndpoint: assert 'logs' in data['data'] assert 'total_count' in data['data'] + called = fake_bot_app.bot_service.list_event_logs + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10) + assert called.await_args.kwargs == {} + assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1} + @pytest.mark.asyncio async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app): access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value @@ -366,7 +419,7 @@ class TestBotSendMessageEndpoint: """Tests for bot send message endpoint.""" @pytest.mark.asyncio - async def test_send_message_success(self, quart_test_client): + async def test_send_message_success(self, quart_test_client, fake_bot_app): """POST /api/v1/platform/bots/{uuid}/send_message sends message.""" response = await quart_test_client.post( '/api/v1/platform/bots/test-bot-uuid/send_message', @@ -383,6 +436,12 @@ class TestBotSendMessageEndpoint: assert data['code'] == 0 assert data['data']['sent'] is True + called = fake_bot_app.bot_service.send_message + called.assert_awaited_once() + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}]) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio async def test_send_message_missing_target_type(self, quart_test_client): """POST send_message without target_type returns 400.""" diff --git a/tests/integration/api/test_box_security.py b/tests/integration/api/test_box_security.py index 0a40f230a..a0409d908 100644 --- a/tests/integration/api/test_box_security.py +++ b/tests/integration/api/test_box_security.py @@ -35,6 +35,7 @@ async def box_security_api(): 'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'), } application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.persistence_mgr = SimpleNamespace(tenant_uow=None) application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token]) diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py index d27b1e002..73204674f 100644 --- a/tests/integration/api/test_embed.py +++ b/tests/integration/api/test_embed.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import os +from pathlib import Path import pytest from unittest.mock import MagicMock, AsyncMock, Mock @@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint: assert 'javascript' in response.content_type fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc') + body = await response.get_data(as_text=True) + assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body + assert 'baseUrl: "http://localhost"' in body + assert '__LANGBOT_' not in body + @pytest.mark.asyncio @pytest.mark.parametrize( ('query', 'expected_base'), @@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint: assert response.status_code == 200 assert 'image/webp' in response.content_type + body = await response.get_data() + assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes() + assert body[:4] == b'RIFF' and body[8:12] == b'WEBP' + @pytest.mark.usefixtures('mock_circular_import_chain') class TestEmbedTurnstileVerifyEndpoint: """Tests for Turnstile verification endpoint.""" @pytest.mark.asyncio - async def test_turnstile_verify_no_secret(self, quart_test_client): + async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch): """POST turnstile verify without secret returns dummy token.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25) response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'} ) @@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint: assert data['code'] == 0 assert 'token' in data['data'] + assert data['data']['token'] == '1720000000.25.dummy' + @pytest.mark.asyncio async def test_turnstile_verify_invalid_uuid(self, quart_test_client): """POST turnstile verify with invalid UUID returns 400.""" @@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint: proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID) @pytest.mark.asyncio - async def test_get_messages_group_success(self, quart_test_client): + async def test_get_messages_group_success(self, quart_test_client, fake_embed_app): """GET messages/group returns messages.""" response = await quart_test_client.get( f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}', @@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}] + adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter + adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID) + @pytest.mark.asyncio async def test_get_messages_requires_session_id(self, quart_test_client): """GET messages without a client session identifier returns 400.""" @@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint: async def test_get_messages_invalid_session_type(self, quart_test_client): """GET messages with invalid session_type returns 400.""" response = await quart_test_client.get( - '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid', + f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}', headers={'Authorization': 'Bearer 1234567890.dummy'}, ) assert response.status_code == 400 + assert (await response.get_json())['msg'] == 'session_type must be person or group' + @pytest.mark.usefixtures('mock_circular_import_chain') class TestEmbedResetEndpoint: @@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint: """Tests for feedback submission endpoint.""" @pytest.mark.asyncio - async def test_submit_feedback_like(self, quart_test_client): + async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch): """POST feedback with type=1 (like) succeeds.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0')) + fake_embed_app.monitoring_service.record_feedback.reset_mock() response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, @@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint: assert data['code'] == 0 assert 'feedback_id' in data['data'] + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['feedback_id'] == 'embed_123456789abc' + called = fake_embed_app.monitoring_service.record_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.kwargs == { + 'feedback_id': 'embed_123456789abc', + 'feedback_type': 1, + 'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc', + 'bot_name': 'Test Web Bot', + 'pipeline_id': 'test-pipeline-uuid', + 'message_id': 'msg-123', + 'platform': 'web_page_bot', + } + @pytest.mark.asyncio - async def test_submit_feedback_dislike(self, quart_test_client): + async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch): """POST feedback with type=2 (dislike) succeeds.""" + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0')) + fake_embed_app.monitoring_service.record_feedback.reset_mock() response = await quart_test_client.post( '/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback', headers={'Authorization': 'Bearer 1234567890.dummy'}, @@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data']['feedback_id'] == 'embed_123456789abc' + called = fake_embed_app.monitoring_service.record_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.kwargs == { + 'feedback_id': 'embed_123456789abc', + 'feedback_type': 2, + 'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc', + 'bot_name': 'Test Web Bot', + 'pipeline_id': 'test-pipeline-uuid', + 'message_id': 'msg-123', + 'platform': 'web_page_bot', + } + @pytest.mark.asyncio async def test_submit_feedback_invalid_type(self, quart_test_client): """POST feedback with invalid type returns 400.""" @@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint: """The public socket authenticates before resolving shared runtime state.""" @pytest.mark.asyncio - async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app): + async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.embed as embed + + authenticated = False + original_authenticate = embed.EmbedRouterGroup._authenticate_websocket + original_add = embed.ws_connection_manager.add_connection + proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot + + async def authenticate(router, runtime_bot): + nonlocal authenticated + await original_authenticate(router, runtime_bot) + authenticated = True + + async def get_proxy(*_args, **_kwargs): + assert authenticated, 'Runtime lookup must follow successful authentication' + return proxy.return_value + + async def add_connection(*args, **kwargs): + assert authenticated, 'Connection registration must follow successful authentication' + return await original_add(*args, **kwargs) + + monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate) + monkeypatch.setattr(proxy, 'side_effect', get_proxy) + monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect' f'?session_type=person&session_id={SESSION_ID}', diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py index 13b0785c7..a046da6d4 100644 --- a/tests/integration/api/test_knowledge.py +++ b/tests/integration/api/test_knowledge.py @@ -117,13 +117,13 @@ def fake_knowledge_app(): 'engine_plugin_id': 'test/engine', } ) - app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'}) + app.knowledge_service.create_knowledge_base = AsyncMock(return_value='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.store_file = AsyncMock(return_value={'task_id': 'test-task-id'}) + app.knowledge_service.store_file = AsyncMock(return_value='test-task-id') app.knowledge_service.delete_file = AsyncMock() app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}]) @@ -133,6 +133,11 @@ def fake_knowledge_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_knowledge_app): + fake_knowledge_app.knowledge_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_knowledge_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -148,7 +153,7 @@ class TestKnowledgeBaseEndpoints: """Tests for /api/v1/knowledge/bases endpoints.""" @pytest.mark.asyncio - async def test_get_knowledge_bases_success(self, quart_test_client): + async def test_get_knowledge_bases_success(self, quart_test_client, fake_knowledge_app): """GET /api/v1/knowledge/bases returns knowledge base list.""" response = await quart_test_client.get( '/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'} @@ -160,8 +165,24 @@ class TestKnowledgeBaseEndpoints: assert 'data' in data assert 'bases' in data['data'] + called = fake_knowledge_app.knowledge_service.get_knowledge_bases + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['bases'] == [ + { + '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', + } + ] + @pytest.mark.asyncio - async def test_create_knowledge_base_success(self, quart_test_client): + async def test_create_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases creates new knowledge base.""" response = await quart_test_client.post( '/api/v1/knowledge/bases', @@ -174,8 +195,15 @@ class TestKnowledgeBaseEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_knowledge_app.knowledge_service.create_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-kb-uuid'} + @pytest.mark.asyncio - async def test_get_single_knowledge_base_success(self, quart_test_client): + async def test_get_single_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """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'} @@ -186,8 +214,20 @@ class TestKnowledgeBaseEndpoints: assert data['code'] == 0 assert 'base' in data['data'] + called = fake_knowledge_app.knowledge_service.get_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['base'] == { + 'uuid': 'test-kb-uuid', + 'name': 'Test Knowledge Base', + 'description': 'Test KB description', + 'engine_plugin_id': 'test/engine', + } + @pytest.mark.asyncio - async def test_update_knowledge_base_success(self, quart_test_client): + async def test_update_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """PUT /api/v1/knowledge/bases/{uuid} updates knowledge base.""" response = await quart_test_client.put( '/api/v1/knowledge/bases/test-kb-uuid', @@ -199,8 +239,14 @@ class TestKnowledgeBaseEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_knowledge_app.knowledge_service.update_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_knowledge_base_success(self, quart_test_client): + async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app): """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'} @@ -208,13 +254,20 @@ class TestKnowledgeBaseEndpoints: assert response.status_code == 200 + called = fake_knowledge_app.knowledge_service.delete_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestKnowledgeBaseFilesEndpoints: """Tests for knowledge base files endpoints.""" @pytest.mark.asyncio - async def test_get_files_success(self, quart_test_client): + async def test_get_files_success(self, quart_test_client, fake_knowledge_app): """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'} @@ -225,8 +278,15 @@ class TestKnowledgeBaseFilesEndpoints: assert data['code'] == 0 assert 'files' in data['data'] + called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid',) + assert called.await_args.kwargs == {} + assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}] + @pytest.mark.asyncio - async def test_add_file_to_knowledge_base(self, quart_test_client): + async def test_add_file_to_knowledge_base(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases/{uuid}/files adds file.""" response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/files', @@ -239,8 +299,15 @@ class TestKnowledgeBaseFilesEndpoints: assert data['code'] == 0 assert 'task_id' in data['data'] + called = fake_knowledge_app.knowledge_service.store_file + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id') + assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'} + assert data['data'] == {'task_id': 'test-task-id'} + @pytest.mark.asyncio - async def test_delete_file_from_knowledge_base(self, quart_test_client): + async def test_delete_file_from_knowledge_base(self, quart_test_client, fake_knowledge_app): """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'} @@ -248,13 +315,20 @@ class TestKnowledgeBaseFilesEndpoints: assert response.status_code == 200 + called = fake_knowledge_app.knowledge_service.delete_file + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid') + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestKnowledgeBaseRetrieveEndpoint: """Tests for knowledge base retrieval endpoint.""" @pytest.mark.asyncio - async def test_retrieve_knowledge_success(self, quart_test_client): + async def test_retrieve_knowledge_success(self, quart_test_client, fake_knowledge_app): """POST /api/v1/knowledge/bases/{uuid}/retrieve.""" response = await quart_test_client.post( '/api/v1/knowledge/bases/test-kb-uuid/retrieve', @@ -267,6 +341,13 @@ class TestKnowledgeBaseRetrieveEndpoint: assert data['code'] == 0 assert 'results' in data['data'] + called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5}) + assert called.await_args.kwargs == {} + assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}] + @pytest.mark.asyncio async def test_retrieve_without_query_returns_error(self, quart_test_client): """POST retrieve without query returns 400.""" diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 84320afbe..f10540d41 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q from __future__ import annotations +import csv +import io + import pytest from unittest.mock import MagicMock, AsyncMock, Mock, patch from types import SimpleNamespace @@ -122,17 +125,24 @@ def fake_monitoring_app(): ) 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.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}]) + app.monitoring_service.export_messages = AsyncMock( + return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}] + ) app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}]) app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}]) app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}]) app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}]) app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}]) - app.monitoring_service._escape_csv_field = Mock(return_value='escaped') + app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field return app +@pytest.fixture(autouse=True) +def reset_monitoring_calls(fake_monitoring_app): + fake_monitoring_app.monitoring_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_monitoring_app, http_controller_cls): """Create Quart test client (module scope).""" @@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint: data = await response.get_json() assert data['code'] == 0 + assert data['data'] == { + 'total_messages': 100, + 'total_llm_calls': 50, + 'total_sessions': 20, + 'active_sessions': 5, + 'total_errors': 2, + } + @pytest.mark.asyncio async def test_viewer_can_read_monitoring_but_cannot_export( self, @@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint: assert data['code'] == 0 assert 'messages' in data['data'] + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == { + 'messages': [{'id': 'msg-1', 'content': 'test'}], + 'total': 100, + 'limit': 100, + 'offset': 0, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringLLMCallsEndpoint: @@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringEmbeddingCallsEndpoint: @@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringSessionsEndpoint: @@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0} + @pytest.mark.asyncio async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app): fake_monitoring_app.monitoring_service.get_sessions.reset_mock() @@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringAllDataEndpoint: @@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint: assert 'overview' in data['data'] assert data['data']['traffic'] == traffic + assert data['code'] == 0 + assert data['data']['overview'] == { + 'total_messages': 100, + 'total_llm_calls': 50, + 'total_sessions': 20, + 'active_sessions': 5, + 'total_errors': 2, + } + assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}] + assert data['data']['llmCalls'] == [{'id': 'llm-1'}] + assert data['data']['toolCalls'] == [{'id': 'tool-1'}] + assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}] + assert data['data']['sessions'] == [{'session_id': 'sess-1'}] + assert data['data']['errors'] == [{'id': 'err-1'}] + assert data['data']['totalCount'] == { + 'messages': 100, + 'llmCalls': 50, + 'toolCalls': 5, + 'embeddingCalls': 10, + 'sessions': 20, + 'errors': 2, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringDetailsEndpoints: @@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'found': True, 'message_id': 'msg-1'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringFeedbackEndpoints: @@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'like_count': 10} + @pytest.mark.asyncio async def test_get_feedback_list(self, quart_test_client): """GET /api/v1/monitoring/feedback.""" @@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints: assert response.status_code == 200 + data = await response.get_json() + assert data['code'] == 0 + assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringExportEndpoint: """Tests for /api/v1/monitoring/export endpoint.""" @pytest.mark.asyncio - async def test_export_messages(self, quart_test_client): + async def test_export_messages(self, quart_test_client, fake_monitoring_app): """GET export?type=messages returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'} @@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 assert 'text/csv' in response.content_type + assert 'text/csv' in response.content_type + assert 'monitoring-messages-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'msg-1' + called = fake_monitoring_app.monitoring_service.export_messages + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + assert records[0]['message_content'] == 'hello, "quoted"' + @pytest.mark.asyncio - async def test_export_llm_calls(self, quart_test_client): + async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app): """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'} @@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 + assert 'text/csv' in response.content_type + assert 'monitoring-llm-calls-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'llm-1' + called = fake_monitoring_app.monitoring_service.export_llm_calls + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + @pytest.mark.asyncio - async def test_export_sessions(self, quart_test_client): + async def test_export_sessions(self, quart_test_client, fake_monitoring_app): """GET export?type=sessions returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'} @@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint: assert response.status_code == 200 + assert 'text/csv' in response.content_type + assert 'monitoring-sessions-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['session_id'] == 'sess-1' + called = fake_monitoring_app.monitoring_service.export_sessions + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 + @pytest.mark.asyncio - async def test_export_feedback(self, quart_test_client): + async def test_export_feedback(self, quart_test_client, fake_monitoring_app): """GET export?type=feedback returns CSV.""" response = await quart_test_client.get( '/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 + + assert 'text/csv' in response.content_type + assert 'monitoring-feedback-' in response.headers['Content-Disposition'] + body = (await response.get_data()).decode('utf-8-sig') + records = list(csv.DictReader(io.StringIO(body))) + assert len(records) == 1 + assert records[0]['id'] == 'fb-1' + called = fake_monitoring_app.monitoring_service.export_feedback + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert called.await_args.kwargs['limit'] == 100000 diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py index 9f84aafb9..db1ec1d54 100644 --- a/tests/integration/api/test_pipelines.py +++ b/tests/integration/api/test_pipelines.py @@ -128,10 +128,10 @@ def fake_pipeline_app(): 'config': {}, } ) - app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'}) + app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid') app.pipeline_service.update_pipeline = AsyncMock(return_value={}) app.pipeline_service.delete_pipeline = AsyncMock() - app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'}) + app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid') # Bot service app.bot_service = Mock() @@ -161,6 +161,11 @@ def fake_pipeline_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_pipeline_app): + fake_pipeline_app.pipeline_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_pipeline_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -179,7 +184,7 @@ class TestPipelineMetadataEndpoint: """Tests for /api/v1/pipelines/_/metadata endpoint.""" @pytest.mark.asyncio - async def test_get_pipeline_metadata_success(self, quart_test_client): + async def test_get_pipeline_metadata_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/_/metadata returns metadata list.""" response = await quart_test_client.get( '/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'} @@ -191,6 +196,13 @@ class TestPipelineMetadataEndpoint: assert 'data' in data assert isinstance(data['data'], dict) + called = fake_pipeline_app.pipeline_service.get_pipeline_metadata + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {} + assert data['data'] == {'configs': [{'name': 'trigger', 'stages': []}, {'name': 'ai', 'stages': []}]} + @pytest.mark.asyncio async def test_get_pipeline_metadata_requires_auth(self, quart_test_client): """Pipeline metadata endpoint requires authentication.""" @@ -203,7 +215,7 @@ class TestPipelinesListEndpoint: """Tests for /api/v1/pipelines endpoint.""" @pytest.mark.asyncio - async def test_get_pipelines_success(self, quart_test_client): + async def test_get_pipelines_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines returns pipeline list.""" response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'}) @@ -212,24 +224,46 @@ class TestPipelinesListEndpoint: assert data['code'] == 0 assert 'data' in data + called = fake_pipeline_app.pipeline_service.get_pipelines + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('created_at', 'DESC') + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['pipelines'] == [ + { + '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, + } + ] + @pytest.mark.asyncio - async def test_get_pipelines_with_sort_param(self, quart_test_client): + async def test_get_pipelines_with_sort_param(self, quart_test_client, fake_pipeline_app): """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=name&sort_order=ASC', headers={'Authorization': 'Bearer test_token'} ) assert response.status_code == 200 data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.get_pipelines + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('name', 'ASC') + assert called.await_args.kwargs == {'include_secret': True} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelinesCRUDEndpoints: """Tests for pipeline CRUD operations.""" @pytest.mark.asyncio - async def test_get_single_pipeline_success(self, quart_test_client): + async def test_get_single_pipeline_success(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/{uuid} returns pipeline.""" response = await quart_test_client.get( '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} @@ -240,8 +274,15 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'data' in data + called = fake_pipeline_app.pipeline_service.get_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}} + @pytest.mark.asyncio - async def test_create_pipeline_success(self, quart_test_client): + async def test_create_pipeline_success(self, quart_test_client, fake_pipeline_app): """POST /api/v1/pipelines creates new pipeline.""" response = await quart_test_client.post( '/api/v1/pipelines', @@ -254,6 +295,13 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_pipeline_app.pipeline_service.create_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},) + assert called.await_args.kwargs == {'default': False} + assert data['data'] == {'uuid': 'new-pipeline-uuid'} + @pytest.mark.asyncio async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app): """POST /api/v1/pipelines explicitly creates a default pipeline.""" @@ -271,7 +319,7 @@ class TestPipelinesCRUDEndpoints: assert call.args[1]['is_default'] is True @pytest.mark.asyncio - async def test_update_pipeline_success(self, quart_test_client): + async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app): """PUT /api/v1/pipelines/{uuid} updates pipeline.""" response = await quart_test_client.put( '/api/v1/pipelines/test-pipeline-uuid', @@ -283,8 +331,14 @@ class TestPipelinesCRUDEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.update_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_pipeline_success(self, quart_test_client): + async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app): """DELETE /api/v1/pipelines/{uuid} deletes pipeline.""" response = await quart_test_client.delete( '/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'} @@ -294,8 +348,14 @@ class TestPipelinesCRUDEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.delete_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_copy_pipeline_success(self, quart_test_client): + async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app): """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'} @@ -306,14 +366,24 @@ class TestPipelinesCRUDEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_pipeline_app.pipeline_service.copy_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'copied-pipeline-uuid'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineExtensionsEndpoint: """Tests for pipeline extensions.""" @pytest.mark.asyncio - async def test_get_extensions(self, quart_test_client): + async def test_get_extensions(self, quart_test_client, fake_pipeline_app): """GET /api/v1/pipelines/{uuid}/extensions.""" + fake_pipeline_app.plugin_connector.list_plugins.return_value = [{'name': 'extension-plugin'}] + fake_pipeline_app.mcp_service.get_mcp_servers.return_value = [{'uuid': 'extension-mcp'}] + fake_pipeline_app.skill_service.list_skills.return_value = [{'name': 'extension-skill'}] response = await quart_test_client.get( '/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'} ) @@ -323,6 +393,21 @@ class TestPipelineExtensionsEndpoint: data = await response.get_json() assert data['code'] == 0 + called = fake_pipeline_app.pipeline_service.get_pipeline + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-pipeline-uuid',) + assert called.await_args.kwargs == {} + assert data['data']['available_plugins'] == [{'name': 'extension-plugin'}] + assert data['data']['available_mcp_servers'] == [{'uuid': 'extension-mcp'}] + assert data['data']['available_skills'] == [{'name': 'extension-skill'}] + assert data['data']['bound_plugins'] == [] + assert data['data']['bound_mcp_servers'] == [] + assert data['data']['bound_skills'] == [] + assert data['data']['enable_all_plugins'] is False + assert data['data']['enable_all_mcp_servers'] is False + assert data['data']['enable_all_skills'] is False + @pytest.mark.asyncio async def test_get_extensions_redacts_available_plugin_secrets( self, @@ -377,7 +462,31 @@ class TestPipelineExtensionsEndpoint: @pytest.mark.usefixtures('mock_circular_import_chain') class TestPipelineDashboardWebSocket: @pytest.mark.asyncio - async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app): + async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat + + authenticated = False + original_authenticate = chat.WebSocketChatRouterGroup._authenticate_websocket + original_add = chat.ws_connection_manager.add_connection + proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot + + async def authenticate(router): + nonlocal authenticated + result = await original_authenticate(router) + authenticated = True + return result + + async def get_proxy(*_args, **_kwargs): + assert authenticated, 'Runtime lookup must follow authentication' + return proxy.return_value + + async def add_connection(*args, **kwargs): + assert authenticated, 'Connection registration must follow authentication' + return await original_add(*args, **kwargs) + + monkeypatch.setattr(chat.WebSocketChatRouterGroup, '_authenticate_websocket', authenticate) + monkeypatch.setattr(proxy, 'side_effect', get_proxy) + monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person', headers={'Origin': 'http://localhost'}, @@ -403,7 +512,13 @@ class TestPipelineDashboardWebSocket: fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited() @pytest.mark.asyncio - async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client): + async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client, fake_pipeline_app, monkeypatch): + import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat + + proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot + proxy.reset_mock() + add_connection = AsyncMock(wraps=chat.ws_connection_manager.add_connection) + monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection) async with quart_test_client.websocket( '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person', headers={'Origin': 'http://localhost'}, @@ -412,6 +527,9 @@ class TestPipelineDashboardWebSocket: response = json.loads(await websocket.receive()) assert response == {'type': 'error', 'message': 'Unauthorized'} + proxy.assert_not_awaited() + add_connection.assert_not_awaited() + @pytest.mark.asyncio async def test_websocket_rechecks_revocable_membership_before_each_message( self, diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index 52442e371..2f773595a 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -88,6 +88,7 @@ async def plugin_security_api(plugin_module): } application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token]) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py index df72d9084..cc01466b4 100644 --- a/tests/integration/api/test_providers.py +++ b/tests/integration/api/test_providers.py @@ -109,21 +109,23 @@ def fake_provider_app(): 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.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'}) + app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid') app.llm_model_service.update_llm_model = AsyncMock(return_value={}) app.llm_model_service.delete_llm_model = AsyncMock() app.llm_model_service.test_llm_model = AsyncMock() # Embedding model service app.embedding_models_service = Mock() - app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[]) - app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'}) + app.embedding_models_service.get_embedding_models = AsyncMock( + return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}] + ) + app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid') app.embedding_models_service.test_embedding_model = AsyncMock() # Rerank model service app.rerank_models_service = Mock() - app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[]) - app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'}) + app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}]) + app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid') app.rerank_models_service.test_rerank_model = AsyncMock() # Model manager @@ -134,6 +136,14 @@ def fake_provider_app(): return app +@pytest.fixture(autouse=True) +def reset_service_calls(fake_provider_app): + fake_provider_app.provider_service.reset_mock() + fake_provider_app.llm_model_service.reset_mock() + fake_provider_app.embedding_models_service.reset_mock() + fake_provider_app.rerank_models_service.reset_mock() + + @pytest.fixture(scope='module') async def quart_test_client(fake_provider_app, http_controller_cls): """Create Quart test client (module scope to avoid route re-registration).""" @@ -206,7 +216,7 @@ class TestProviderEndpoints: assert data['data']['uuid'] == 'new-provider-uuid' @pytest.mark.asyncio - async def test_update_provider_success(self, quart_test_client): + async def test_update_provider_success(self, quart_test_client, fake_provider_app): """PUT /api/v1/provider/providers/{uuid} updates provider.""" response = await quart_test_client.put( '/api/v1/provider/providers/test-provider-uuid', @@ -218,8 +228,14 @@ class TestProviderEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_provider_app.provider_service.update_provider + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'}) + assert called.await_args.kwargs == {} + @pytest.mark.asyncio - async def test_delete_provider_success(self, quart_test_client): + async def test_delete_provider_success(self, quart_test_client, fake_provider_app): """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'} @@ -227,8 +243,15 @@ class TestProviderEndpoints: assert response.status_code == 200 + called = fake_provider_app.provider_service.delete_provider + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.asyncio - async def test_get_provider_includes_model_counts(self, quart_test_client): + async def test_get_provider_includes_model_counts(self, quart_test_client, fake_provider_app): """GET provider response includes model counts.""" response = await quart_test_client.get( '/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'} @@ -243,13 +266,24 @@ class TestProviderEndpoints: assert 'embedding_count' in provider_data assert 'rerank_count' in provider_data + called = fake_provider_app.provider_service.get_provider_model_counts + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-provider-uuid',) + assert called.await_args.kwargs == {} + assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == { + 'llm_count': 2, + 'embedding_count': 1, + 'rerank_count': 0, + } + @pytest.mark.usefixtures('mock_circular_import_chain') class TestModelEndpoints: """Tests for /api/v1/provider/models endpoints.""" @pytest.mark.asyncio - async def test_get_llm_models_success(self, quart_test_client): + async def test_get_llm_models_success(self, quart_test_client, fake_provider_app): """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'} @@ -260,8 +294,15 @@ class TestModelEndpoints: assert data['code'] == 0 assert 'data' in data + called = fake_provider_app.llm_model_service.get_llm_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'test-model-uuid', 'name': 'gpt-4'}] + @pytest.mark.asyncio - async def test_get_single_llm_model_success(self, quart_test_client): + async def test_get_single_llm_model_success(self, quart_test_client, fake_provider_app): """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'} @@ -271,8 +312,15 @@ class TestModelEndpoints: data = await response.get_json() assert data['code'] == 0 + called = fake_provider_app.llm_model_service.get_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-model-uuid',) + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'} + @pytest.mark.asyncio - async def test_create_llm_model_success(self, quart_test_client): + async def test_create_llm_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/llm creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/llm', @@ -285,8 +333,15 @@ class TestModelEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_provider_app.llm_model_service.create_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-model-uuid'} + @pytest.mark.asyncio - async def test_delete_llm_model_success(self, quart_test_client): + async def test_delete_llm_model_success(self, quart_test_client, fake_provider_app): """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'} @@ -294,6 +349,13 @@ class TestModelEndpoints: assert response.status_code == 200 + called = fake_provider_app.llm_model_service.delete_llm_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ('test-model-uuid',) + assert called.await_args.kwargs == {} + assert (await response.get_json())['code'] == 0 + @pytest.mark.asyncio @pytest.mark.parametrize( ('service_name', 'method_name', 'path'), @@ -329,7 +391,7 @@ class TestModelEndpoints: assert response.status_code == 400 data = await response.get_json() assert data['code'] == 'model_test_failed' - assert data['msg'] == '模型请求失败: API key invalid' + assert data['msg'] == 'Model request failed: API key invalid' @pytest.mark.usefixtures('mock_circular_import_chain') @@ -337,7 +399,7 @@ class TestEmbeddingModelEndpoints: """Tests for /api/v1/provider/models/embedding endpoints.""" @pytest.mark.asyncio - async def test_get_embedding_models_success(self, quart_test_client): + async def test_get_embedding_models_success(self, quart_test_client, fake_provider_app): """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'} @@ -348,8 +410,15 @@ class TestEmbeddingModelEndpoints: assert data['code'] == 0 assert 'models' in data['data'] + called = fake_provider_app.embedding_models_service.get_embedding_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'embedding-model', 'name': 'Embedding'}] + @pytest.mark.asyncio - async def test_create_embedding_model_success(self, quart_test_client): + async def test_create_embedding_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/embedding creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/embedding', @@ -362,13 +431,20 @@ class TestEmbeddingModelEndpoints: assert data['code'] == 0 assert 'uuid' in data['data'] + called = fake_provider_app.embedding_models_service.create_embedding_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-embedding-uuid'} + @pytest.mark.usefixtures('mock_circular_import_chain') class TestRerankModelEndpoints: """Tests for /api/v1/provider/models/rerank endpoints.""" @pytest.mark.asyncio - async def test_get_rerank_models_success(self, quart_test_client): + async def test_get_rerank_models_success(self, quart_test_client, fake_provider_app): """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'} @@ -379,8 +455,15 @@ class TestRerankModelEndpoints: assert data['code'] == 0 assert 'models' in data['data'] + called = fake_provider_app.rerank_models_service.get_rerank_models + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == () + assert called.await_args.kwargs == {'include_secret': True} + assert data['data']['models'] == [{'uuid': 'rerank-model', 'name': 'Rerank'}] + @pytest.mark.asyncio - async def test_create_rerank_model_success(self, quart_test_client): + async def test_create_rerank_model_success(self, quart_test_client, fake_provider_app): """POST /api/v1/provider/models/rerank creates new model.""" response = await quart_test_client.post( '/api/v1/provider/models/rerank', @@ -392,3 +475,10 @@ class TestRerankModelEndpoints: data = await response.get_json() assert data['code'] == 0 assert 'uuid' in data['data'] + + called = fake_provider_app.rerank_models_service.create_rerank_model + assert called.await_count == 1 + assert called.await_args.args[0].workspace_uuid == 'workspace-test' + assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},) + assert called.await_args.kwargs == {} + assert data['data'] == {'uuid': 'new-rerank-uuid'} diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py index baafe1f18..0df480e27 100644 --- a/tests/integration/api/test_skills_entitlements.py +++ b/tests/integration/api/test_skills_entitlements.py @@ -1,4 +1,4 @@ -"""Skills API behavior when a workspace plan has no managed sandbox.""" +"""Skills API behavior is independent from managed sandbox entitlement.""" from __future__ import annotations @@ -9,10 +9,7 @@ import pytest import quart from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup -from langbot.pkg.cloud.entitlements import ( - EntitlementFeatureUnavailableError, - EntitlementUnavailableError, -) +from langbot.pkg.cloud.entitlements import EntitlementUnavailableError pytestmark = pytest.mark.integration WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111' @@ -27,15 +24,13 @@ async def skills_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.persistence_mgr = SimpleNamespace(tenant_uow=None) application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) application.skill_service.list_skills = AsyncMock( - side_effect=EntitlementFeatureUnavailableError( - 'managed_sandbox', - entitlement_revision=1, - ) + return_value=[{'name': 'docs-only', 'description': 'No execution required'}] ) quart_app = quart.Quart(__name__) @@ -45,7 +40,7 @@ async def skills_api(): @pytest.mark.asyncio -async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api): +async def test_list_skills_remains_available_without_managed_sandbox(skills_api): application, client = skills_api response = await client.get( '/api/v1/skills', @@ -57,7 +52,7 @@ async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api) assert response.status_code == 200 payload = await response.get_json() - assert payload['data'] == {'skills': []} + assert payload['data'] == {'skills': [{'name': 'docs-only', 'description': 'No execution required'}]} application.skill_service.list_skills.assert_awaited_once() @@ -76,4 +71,6 @@ async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api): }, ) - assert response.status_code == 500 + assert response.status_code == 403 + payload = await response.get_json() + assert payload['code'] == 'entitlement_unavailable' diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py index 864fa374f..14ca4579f 100644 --- a/tests/integration/api/test_smoke.py +++ b/tests/integration/api/test_smoke.py @@ -164,16 +164,6 @@ class TestHealthEndpoint: data = await response.get_json() assert data == {'code': 0, 'msg': 'ok', 'resources': {}} - @pytest.mark.asyncio - async def test_healthz_no_auth_required(self, quart_test_client): - """ - /healthz doesn't require authentication. - - Tests that AuthType.NONE endpoints work without headers. - """ - response = await quart_test_client.get('/healthz') - assert response.status_code == 200 - @pytest.mark.usefixtures('mock_circular_import_chain') class TestSystemEndpoint: @@ -241,33 +231,42 @@ class TestInvalidPayload: """Tests for error handling with invalid payloads.""" @pytest.mark.asyncio - async def test_missing_json_body(self, quart_test_client): - """ - POST endpoint without JSON body handles gracefully. - """ - # /api/v1/user/auth expects JSON with 'user' and 'password' + async def test_missing_json_body(self, quart_test_client, fake_api_app): + """A missing login body is rejected before authentication.""" response = await quart_test_client.post('/api/v1/user/auth') - # Should return error (500, 400, or 401) with stable JSON structure - assert response.status_code in (400, 500, 401) - data = await response.get_json() - - # Verify error response has expected structure - assert 'code' in data - assert 'msg' in data + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'} + fake_api_app.user_service.authenticate.assert_not_awaited() @pytest.mark.asyncio - async def test_invalid_json_structure(self, quart_test_client): - """ - POST with wrong JSON structure returns stable error. - """ - response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'}) + @pytest.mark.parametrize( + 'payload', + [ + {'wrong_field': 'value'}, + [], + {'user': None, 'password': 'secret'}, + {'user': 'owner@example.com', 'password': 42}, + ], + ) + async def test_invalid_json_structure(self, quart_test_client, fake_api_app, payload): + """Malformed credential fields never reach the authentication service.""" + response = await quart_test_client.post('/api/v1/user/auth', json=payload) - # Should return error with stable JSON structure - assert response.status_code in (400, 500, 401) - data = await response.get_json() - assert 'code' in data - assert 'msg' in data + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'} + fake_api_app.user_service.authenticate.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize('password', ['', 'existing-password']) + async def test_string_credentials_reach_authentication(self, quart_test_client, fake_api_app, password): + response = await quart_test_client.post( + '/api/v1/user/auth', json={'user': 'owner@example.com', 'password': password} + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['token'] == 'fake_token' + fake_api_app.user_service.authenticate.assert_awaited_once_with('owner@example.com', password, None) @pytest.mark.usefixtures('mock_circular_import_chain') diff --git a/tests/integration/api/test_support_admin_launch.py b/tests/integration/api/test_support_admin_launch.py index e71cb4374..85913eb41 100644 --- a/tests/integration/api/test_support_admin_launch.py +++ b/tests/integration/api/test_support_admin_launch.py @@ -24,6 +24,7 @@ from langbot.pkg.api.http.controller.groups.user import UserRouterGroup from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService from langbot.pkg.cloud.support_admin import SupportAdminSessionService from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession from langbot.pkg.entity.persistence.user import User from langbot.pkg.entity.persistence.workspace import ( @@ -124,6 +125,9 @@ class _TenantUow: self._transaction = await self.session.begin() return self + async def execute(self, statement): + return await self.session.execute(statement) + async def __aexit__(self, exc_type, exc, traceback): try: if exc_type is None: @@ -173,6 +177,7 @@ async def support_admin_api(tmp_path): tables=[ User.__table__, Workspace.__table__, + WorkspaceMetadata.__table__, WorkspaceExecutionState.__table__, WorkspaceMembership.__table__, SupportAdminTemporarySession.__table__, @@ -400,7 +405,7 @@ async def test_support_admin_persisted_expiry_and_revocation_are_enforced(suppor async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api): - app, _client, _engine, private_key = support_admin_api + app, _client, engine, private_key = support_admin_api launch = await _issue_support_token(app, private_key) captured_contexts = [] @@ -409,7 +414,6 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a del data captured_contexts.append(connection.execution_context) await connection.send_queue.put({'type': 'handled'}) - connection.is_active = False app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1'))) app.platform_mgr = SimpleNamespace( @@ -434,8 +438,17 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]})) handled = json.loads(await websocket.receive()) assert handled['type'] == 'handled' + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(SupportAdminTemporarySession) + .where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash']) + .values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)) + ) + await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'after revoke'}]})) + rejected = json.loads(await websocket.receive()) + assert rejected == {'type': 'error', 'message': 'Unauthorized'} - assert captured_contexts + assert len(captured_contexts) == 1 principal = captured_contexts[0].trigger_principal assert principal is not None assert principal.principal_type == PrincipalType.SUPPORT_ADMIN diff --git a/tests/integration/api/test_totp_workspace_scope.py b/tests/integration/api/test_totp_workspace_scope.py index 460dcbbb2..4f822062b 100644 --- a/tests/integration/api/test_totp_workspace_scope.py +++ b/tests/integration/api/test_totp_workspace_scope.py @@ -46,6 +46,7 @@ async def _resolve(account_uuid: str, _workspace_uuid: str | None) -> SimpleName async def totp_admin_api(): accounts = {'member-token': SimpleNamespace(uuid=MEMBER_UUID, user='member@example.com')} application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False) application.instance_config.data = {'system': {'allow_modify_login_info': True}} application.persistence_mgr = SimpleNamespace(tenant_uow=None) diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index af2bab140..f867e4a4f 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -27,6 +27,7 @@ async def space_oauth_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() + application._message_language_cache = None application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss') application.directory_projection_service = None application.persistence_mgr = None @@ -233,39 +234,6 @@ async def test_redirect_rejects_invalid_callback_shape(space_oauth_api): assert all(payload['code'] == 1 for payload in payloads) -@pytest.mark.asyncio -async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api): - application, client = space_oauth_api - application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'} - - response = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'}, - headers={'Origin': 'https://irrelevant.example'}, - ) - - assert response.status_code == 200 - assert (await response.get_json())['code'] == 0 - - -@pytest.mark.asyncio -async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api): - application, client = space_oauth_api - application.instance_config.data['api'] = { - 'webui_url': '', - 'webhook_prefix': 'https://langbot.example/base/path', - } - - response = await client.get( - '/api/v1/user/space/authorize-url', - query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'}, - headers={'Host': 'attacker.example'}, - ) - - assert response.status_code == 200 - assert (await response.get_json())['code'] == 0 - - @pytest.mark.asyncio async def test_login_callback_requires_and_consumes_server_state(space_oauth_api): application, client = space_oauth_api @@ -422,6 +390,9 @@ async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oa ) assert response.status_code == 200 + payload = await response.get_json() + assert payload['code'] == 0 + assert payload['data']['token'] == 'space-login-token' application.directory_projection_service.reconcile_workspaces.assert_not_awaited() @@ -556,6 +527,7 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api): application, client = space_oauth_api application.user_service.consume_space_oauth_state.reset_mock() + application.user_service.consume_space_oauth_state_details.reset_mock() application.space_service.exchange_oauth_code.reset_mock() application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) @@ -577,6 +549,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space expected_workspace_uuid=WORKSPACE_UUID, ) application.user_service.consume_space_oauth_state.assert_not_awaited() + application.user_service.consume_space_oauth_state_details.assert_not_awaited() application.space_service.exchange_oauth_code.assert_not_awaited() @@ -590,7 +563,24 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access( status='active', ) application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account) - application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock()) + events = [] + + async def reconcile(_workspaces): + events.append('reconcile') + + async def get_account(_account_uuid): + events.append('account') + return projected_account + + access = application.workspace_collaboration_service.resolve_account_workspace.return_value + + async def resolve_access(*_args, **_kwargs): + events.append('access') + return access + + application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock(side_effect=reconcile)) + application.user_service.get_user_by_uuid.side_effect = get_account + application.workspace_collaboration_service.resolve_account_workspace.side_effect = resolve_access response = await client.post( '/api/v1/user/space/callback', @@ -604,3 +594,5 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access( assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,)) application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a') + + assert events == ['reconcile', 'account', 'access'] diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index a561cd44c..568606cc3 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -623,6 +623,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi ) assert expired_capabilities.status_code == 401 + await application.persistence_mgr.execute_async( + sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.uuid == created['uuid']).values(expires_at=None) + ) + valid_again = await client.get('/api/v1/system/context', headers={'X-API-Key': created['key']}) + assert valid_again.status_code == 200 + revoke_response = await client.delete( f'/api/v1/apikeys/{created["id"]}', headers=_auth(owner_token, workspace_uuid), @@ -824,6 +830,10 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in not_before=1, expires_at=4102444800, plan_name='free', + plan_revision=3, + monthly_bonus_credits=12, + subscription_started_at=1720000000, + limits={'bots.max': 2, 'pipelines.max': 3, 'plugins.max': 4, 'knowledge_bases.max': 5}, ) application.entitlement_resolver = PlanResolver() @@ -843,6 +853,11 @@ async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in assert current['workspace']['source'] == 'cloud_projection' assert current['placement_generation'] == 12 assert current['plan_name'] == 'free' + assert current['limitation']['max_bots'] == 2 + assert current['current_plan']['subscription_started_at'] == 1720000000 + assert current['current_plan']['revision'] == 3 + assert current['current_plan']['monthly_bonus_credits'] == 12 + assert current['current_plan']['limits']['bots.max'] == 2 create_workspace = await client.post( '/api/v1/workspaces', @@ -926,3 +941,64 @@ async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspa ) assert current.status_code == 404 assert (await current.get_json())['code'] == 'resource_not_found' + + +async def test_workspace_language_persists_and_controls_api_errors(workspace_api): + from langbot.pkg.i18n.workspace import get_workspace_language, set_workspace_language + + application, client, engine, owner_token = workspace_api + current = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token)) + current = (await current.get_json())['data'] + workspace_uuid = current['workspace']['uuid'] + owner_uuid = current['membership']['account_uuid'] + headers = {**_auth(owner_token, workspace_uuid), 'X-LangBot-Language': 'ja-JP'} + initial = await client.get('/api/v1/workspaces/current/language', headers=headers) + assert (await initial.get_json())['data']['language'] == 'en_US' + + updated = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'zh_Hans'}) + assert updated.status_code == 200 + assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans' + restarted = SimpleNamespace(persistence_mgr=application.persistence_mgr) + assert await get_workspace_language(restarted, workspace_uuid) == 'zh_Hans' + + invalid = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': '../bad'}) + assert invalid.status_code == 400 + assert await get_workspace_language(application, workspace_uuid, refresh=True) == 'zh_Hans' + + # A second Workspace has independent persisted metadata and cache entries. + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.insert(Workspace).values( + uuid='language-other', + instance_uuid='instance-workspace-api', + name='Other', + slug='language-other', + source='cloud_projection', + type='team', + ) + ) + await set_workspace_language(application, 'language-other', 'es_ES') + assert await get_workspace_language(application, 'language-other') == 'es_ES' + assert await get_workspace_language(application, workspace_uuid) == 'zh_Hans' + + # Read-only members can inspect the setting but cannot modify it. The + # verified Workspace setting wins over the browser language hint. + async with engine.begin() as connection: + await connection.execute( + sqlalchemy.update(WorkspaceMembership) + .where( + WorkspaceMembership.workspace_uuid == workspace_uuid, + WorkspaceMembership.account_uuid == owner_uuid, + ) + .values(role='viewer') + ) + denied = await client.put('/api/v1/workspaces/current/language', headers=headers, json={'language': 'ja_JP'}) + assert denied.status_code == 403 + assert (await denied.get_json())['msg'] == '没有执行此操作的权限' + visible = await client.get('/api/v1/workspaces/current/language', headers=headers) + assert visible.status_code == 200 + assert (await visible.get_json())['data']['language'] == 'zh_Hans' + + # An untrusted selector never causes us to read another tenant's language. + unauthorized = await client.get('/api/v1/workspaces/current/language', headers=_auth(owner_token, 'language-other')) + assert unauthorized.status_code in (403, 404) diff --git a/tests/integration/persistence/fixtures/release_4_10_11_audit.md b/tests/integration/persistence/fixtures/release_4_10_11_audit.md new file mode 100644 index 000000000..768c64c6d --- /dev/null +++ b/tests/integration/persistence/fixtures/release_4_10_11_audit.md @@ -0,0 +1,39 @@ +# v4.10.11 database upgrade audit + +Baseline: `v4.10.11`, commit `273b8839b91cb74dfd083644efea18849a3fcee1`. +Audited target: `48c6a1f536bab421cbaab98a1e880fe767034463`. + +## Changes covered + +- Agent definitions, run ledger, interactions/delivery results, event logs and transcripts. +- Bot legacy pipeline routing to event bindings, followed by legacy-column removal; plugin processors. +- Workspace tenancy/ownership repairs and migration branch merges. +- Unified runner state, assistant conversations and pipeline migration snapshots. +- Passkey and TOTP credentials. +- Knowledge-base initialization/drafts and engine document identity. +- Operation traceability, monitoring links/tool calls, execution host leases and metadata text storage. + +The existing migration chain covers the audited ORM changes. No additional runtime migration was required by the exercised upgrade paths. + +## Added regression + +`release_4_10_11_schema.json` contains immutable dialect-specific DDL exported from the tagged release ORM, rather than current metadata. The test applies historical migrations through `0023_bot_scoped_sessions` to include migration-owned objects such as pgvector, seeds representative legacy records, then runs the current persistence schema initialization twice. + +Assertions cover legacy bot/pipeline preservation, converted bot event bindings, default plugin processors, initialized knowledge bases, SQLite foreign-key consistency, and missing tables/columns/indexes/constraints or column type/nullability differences against current metadata. Equivalent existing index/constraint column sets are accepted; extra migration-owned objects and removed-object diffs are not treated as missing schema. This is representative upgrade coverage, not an exhaustive test of every possible historical data value. + +## Executed verification + +Disposable SQLite files and an isolated PostgreSQL/pgvector container were used; no business database was changed. + +- New tagged-release upgrade test: **2 passed** (SQLite and PostgreSQL), including repeated initialization. +- Existing PostgreSQL migration / release-migration / RAG identity suite: **85 passed, 4 failed** on the first completed run. The four failures were release configuration validation because the test URL omitted the required port. +- Release-migration suite rerun with explicit `:5432`: **5 passed**. +- Ruff check and format: passed for the new regression test. + +Reproduce the new regression with `TEST_POSTGRES_URL` pointing to a disposable PostgreSQL/pgvector instance whose test role can create/drop databases: + +```sh +pytest tests/integration/persistence/test_release_41011_upgrade.py -q +``` + +Without `TEST_POSTGRES_URL`, PostgreSQL is explicitly skipped rather than reported as tested. diff --git a/tests/integration/persistence/fixtures/release_4_10_11_schema.json b/tests/integration/persistence/fixtures/release_4_10_11_schema.json new file mode 100644 index 000000000..cf8a2beda --- /dev/null +++ b/tests/integration/persistence/fixtures/release_4_10_11_schema.json @@ -0,0 +1,250 @@ +{ + "source_commit": "273b8839b91cb74dfd083644efea18849a3fcee1", + "revision": "0023_bot_scoped_sessions", + "dialects": { + "sqlite": [ + "\nCREATE TABLE directory_projection_inbox (\n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tevent_uuid VARCHAR(36) NOT NULL, \n\tcursor BIGINT NOT NULL, \n\tevent_type VARCHAR(128) NOT NULL, \n\trevision BIGINT NOT NULL, \n\tfingerprint TEXT NOT NULL, \n\treceived_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tapplied_at DATETIME, \n\tPRIMARY KEY (instance_uuid, event_uuid), \n\tCONSTRAINT uq_directory_projection_inbox_cursor UNIQUE (instance_uuid, cursor), \n\tCONSTRAINT ck_directory_projection_inbox_cursor CHECK (cursor > 0), \n\tCONSTRAINT ck_directory_projection_inbox_revision CHECK (revision > 0), \n\tCONSTRAINT ck_directory_projection_inbox_fingerprint CHECK (length(fingerprint) = 64)\n)\n\n", + "CREATE INDEX ix_directory_projection_inbox_pending ON directory_projection_inbox (instance_uuid, applied_at, cursor)", + "\nCREATE TABLE directory_projection_states (\n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tcursor BIGINT DEFAULT '0' NOT NULL, \n\tsnapshot_coverage_cursor BIGINT DEFAULT '0' NOT NULL, \n\tsnapshot_fingerprint TEXT NOT NULL, \n\tlast_applied_at DATETIME NOT NULL, \n\tlease_expires_at DATETIME, \n\tPRIMARY KEY (instance_uuid), \n\tCONSTRAINT ck_directory_projection_state_cursor CHECK (cursor >= 0), \n\tCONSTRAINT ck_directory_projection_state_snapshot_coverage CHECK (snapshot_coverage_cursor >= 0 AND snapshot_coverage_cursor <= cursor), \n\tCONSTRAINT ck_directory_projection_state_fingerprint CHECK (length(snapshot_fingerprint) = 64)\n)\n\n", + "\nCREATE TABLE metadata (\n\t\"key\" VARCHAR(255) NOT NULL, \n\tvalue VARCHAR(255), \n\tPRIMARY KEY (\"key\")\n)\n\n", + "\nCREATE TABLE users (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tuser VARCHAR(255) NOT NULL, \n\tnormalized_email VARCHAR(320) NOT NULL, \n\tpassword VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\taccount_type VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tspace_account_uuid VARCHAR(255), \n\tspace_access_token TEXT, \n\tspace_refresh_token TEXT, \n\tspace_access_token_expires_at DATETIME, \n\tspace_api_key VARCHAR(255), \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT ck_users_normalized_email CHECK (normalized_email = trim(normalized_email) AND length(normalized_email) > 0 AND length(normalized_email) <= 320), \n\tCONSTRAINT ck_users_status CHECK (status IN ('active', 'disabled', 'deleted')), \n\tCONSTRAINT ck_users_source CHECK (source IN ('local', 'cloud_projection'))\n)\n\n", + "CREATE UNIQUE INDEX uq_users_normalized_email ON users (normalized_email)", + "CREATE UNIQUE INDEX uq_users_uuid ON users (uuid)", + "\nCREATE TABLE workspaces (\n\tuuid VARCHAR(36) NOT NULL, \n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tslug VARCHAR(255) NOT NULL, \n\ttype VARCHAR(32) DEFAULT 'team' NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tcreated_by_account_uuid VARCHAR(36), \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_workspaces_instance_slug UNIQUE (instance_uuid, slug), \n\tCONSTRAINT ck_workspaces_type CHECK (type IN ('personal', 'team')), \n\tCONSTRAINT ck_workspaces_status CHECK (status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')), \n\tCONSTRAINT ck_workspaces_source CHECK (source IN ('local', 'cloud_projection')), \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_workspaces_instance_status ON workspaces (instance_uuid, status)", + "CREATE UNIQUE INDEX uq_workspaces_local_instance ON workspaces (instance_uuid) WHERE source = 'local'", + "\nCREATE TABLE api_keys (\n\tid INTEGER NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tcreated_by_account_uuid VARCHAR(36), \n\tname VARCHAR(255) NOT NULL, \n\tkey_hash VARCHAR(64) NOT NULL, \n\tscopes JSON DEFAULT '[]' NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\texpires_at DATETIME, \n\tlast_used_at DATETIME, \n\tdescription VARCHAR(512), \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT ck_api_keys_status CHECK (status IN ('active', 'revoked')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_api_keys_workspace_name ON api_keys (workspace_uuid, name)", + "CREATE INDEX ix_api_keys_workspace_status ON api_keys (workspace_uuid, status)", + "CREATE UNIQUE INDEX uq_api_keys_key_hash ON api_keys (key_hash)", + "CREATE UNIQUE INDEX uq_api_keys_uuid ON api_keys (uuid)", + "\nCREATE TABLE binary_storages (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tunique_key VARCHAR(255) NOT NULL, \n\t\"key\" VARCHAR(255) NOT NULL, \n\towner_type VARCHAR(255) NOT NULL, \n\towner VARCHAR(255) NOT NULL, \n\tvalue BLOB NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (workspace_uuid, unique_key), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_binary_storages_workspace_owner ON binary_storages (workspace_uuid, owner_type, owner)", + "\nCREATE TABLE bots (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tdescription VARCHAR(255) NOT NULL, \n\tadapter VARCHAR(255) NOT NULL, \n\tadapter_config JSON NOT NULL, \n\tenable BOOLEAN NOT NULL, \n\tuse_pipeline_name VARCHAR(255), \n\tuse_pipeline_uuid VARCHAR(255), \n\tpipeline_routing_rules JSON DEFAULT '[]' NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_bots_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_bots_workspace_name ON bots (workspace_uuid, name)", + "\nCREATE TABLE knowledge_bases (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR, \n\tdescription TEXT, \n\temoji VARCHAR(10), \n\tcreated_at DATETIME, \n\tupdated_at DATETIME, \n\tknowledge_engine_plugin_id VARCHAR, \n\tcollection_id VARCHAR, \n\tlegacy_vector_collection BOOLEAN DEFAULT 0 NOT NULL, \n\tcreation_settings JSON, \n\tretrieval_settings JSON, \n\tembedding_dimension INTEGER, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_knowledge_bases_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tCONSTRAINT ck_knowledge_bases_embedding_dimension_positive CHECK (embedding_dimension IS NULL OR embedding_dimension > 0), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_bases_name ON knowledge_bases (name)", + "CREATE INDEX ix_knowledge_bases_workspace_name ON knowledge_bases (workspace_uuid, name)", + "CREATE UNIQUE INDEX uq_knowledge_bases_workspace_collection ON knowledge_bases (workspace_uuid, collection_id) WHERE collection_id IS NOT NULL", + "\nCREATE TABLE legacy_pipelines (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tdescription VARCHAR(255) NOT NULL, \n\temoji VARCHAR(10), \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tfor_version VARCHAR(255) NOT NULL, \n\tis_default BOOLEAN NOT NULL, \n\tstages JSON NOT NULL, \n\tconfig JSON NOT NULL, \n\textensions_preferences JSON NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_legacy_pipelines_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_legacy_pipelines_workspace_default ON legacy_pipelines (workspace_uuid, is_default)", + "CREATE INDEX ix_legacy_pipelines_workspace_name ON legacy_pipelines (workspace_uuid, name)", + "\nCREATE TABLE mcp_servers (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tenable BOOLEAN NOT NULL, \n\tmode VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\treadme TEXT DEFAULT '' NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_mcp_servers_workspace_name UNIQUE (workspace_uuid, name), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_mcp_servers_workspace_enable ON mcp_servers (workspace_uuid, enable)", + "\nCREATE TABLE model_providers (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\trequester VARCHAR(255) NOT NULL, \n\tbase_url VARCHAR(512) NOT NULL, \n\tapi_keys JSON NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_model_providers_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_model_providers_workspace_name ON model_providers (workspace_uuid, name)", + "CREATE INDEX ix_model_providers_workspace_requester ON model_providers (workspace_uuid, requester)", + "\nCREATE TABLE monitoring_embedding_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\tmodel_name VARCHAR(255) NOT NULL, \n\tprompt_tokens INTEGER NOT NULL, \n\ttotal_tokens INTEGER NOT NULL, \n\tduration INTEGER NOT NULL, \n\tinput_count INTEGER NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\terror_message TEXT, \n\tknowledge_base_id VARCHAR(255), \n\tquery_text TEXT, \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\tcall_type VARCHAR(50), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_embedding_calls_knowledge_base_id ON monitoring_embedding_calls (knowledge_base_id)", + "CREATE INDEX ix_monitoring_embedding_calls_message_id ON monitoring_embedding_calls (message_id)", + "CREATE INDEX ix_monitoring_embedding_calls_session_id ON monitoring_embedding_calls (session_id)", + "CREATE INDEX ix_monitoring_embedding_calls_timestamp ON monitoring_embedding_calls (timestamp)", + "CREATE INDEX ix_monitoring_embedding_calls_workspace_kb ON monitoring_embedding_calls (workspace_uuid, knowledge_base_id)", + "CREATE INDEX ix_monitoring_embedding_calls_workspace_timestamp ON monitoring_embedding_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_errors (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\terror_type VARCHAR(255) NOT NULL, \n\terror_message TEXT NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255), \n\tstack_trace TEXT, \n\tmessage_id VARCHAR(255), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_errors_bot_id ON monitoring_errors (bot_id)", + "CREATE INDEX ix_monitoring_errors_message_id ON monitoring_errors (message_id)", + "CREATE INDEX ix_monitoring_errors_pipeline_id ON monitoring_errors (pipeline_id)", + "CREATE INDEX ix_monitoring_errors_timestamp ON monitoring_errors (timestamp)", + "CREATE INDEX ix_monitoring_errors_workspace_session ON monitoring_errors (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_errors_workspace_timestamp ON monitoring_errors (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_feedback (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\tfeedback_id VARCHAR(255) NOT NULL, \n\tfeedback_type INTEGER NOT NULL, \n\tfeedback_content TEXT, \n\tinaccurate_reasons TEXT, \n\tbot_id VARCHAR(255), \n\tbot_name VARCHAR(255), \n\tpipeline_id VARCHAR(255), \n\tpipeline_name VARCHAR(255), \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\tstream_id VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tplatform VARCHAR(255), \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_monitoring_feedback_workspace_feedback_id UNIQUE (workspace_uuid, feedback_id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_feedback_bot_id ON monitoring_feedback (bot_id)", + "CREATE INDEX ix_monitoring_feedback_feedback_id ON monitoring_feedback (feedback_id)", + "CREATE INDEX ix_monitoring_feedback_message_id ON monitoring_feedback (message_id)", + "CREATE INDEX ix_monitoring_feedback_pipeline_id ON monitoring_feedback (pipeline_id)", + "CREATE INDEX ix_monitoring_feedback_session_id ON monitoring_feedback (session_id)", + "CREATE INDEX ix_monitoring_feedback_stream_id ON monitoring_feedback (stream_id)", + "CREATE INDEX ix_monitoring_feedback_timestamp ON monitoring_feedback (timestamp)", + "CREATE INDEX ix_monitoring_feedback_workspace_session ON monitoring_feedback (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_feedback_workspace_timestamp ON monitoring_feedback (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_llm_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\tmodel_name VARCHAR(255) NOT NULL, \n\tinput_tokens INTEGER NOT NULL, \n\toutput_tokens INTEGER NOT NULL, \n\ttotal_tokens INTEGER NOT NULL, \n\tduration INTEGER NOT NULL, \n\tcost FLOAT, \n\tstatus VARCHAR(50) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\terror_message TEXT, \n\tmessage_id VARCHAR(255), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_llm_calls_bot_id ON monitoring_llm_calls (bot_id)", + "CREATE INDEX ix_monitoring_llm_calls_message_id ON monitoring_llm_calls (message_id)", + "CREATE INDEX ix_monitoring_llm_calls_pipeline_id ON monitoring_llm_calls (pipeline_id)", + "CREATE INDEX ix_monitoring_llm_calls_timestamp ON monitoring_llm_calls (timestamp)", + "CREATE INDEX ix_monitoring_llm_calls_workspace_session ON monitoring_llm_calls (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_llm_calls_workspace_timestamp ON monitoring_llm_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_messages (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tmessage_content TEXT NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\tlevel VARCHAR(50) NOT NULL, \n\tplatform VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tuser_name VARCHAR(255), \n\trunner_name VARCHAR(255), \n\tvariables TEXT, \n\trole VARCHAR(50), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_messages_bot_id ON monitoring_messages (bot_id)", + "CREATE INDEX ix_monitoring_messages_pipeline_id ON monitoring_messages (pipeline_id)", + "CREATE INDEX ix_monitoring_messages_session_id ON monitoring_messages (session_id)", + "CREATE INDEX ix_monitoring_messages_timestamp ON monitoring_messages (timestamp)", + "CREATE INDEX ix_monitoring_messages_workspace_session ON monitoring_messages (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_messages_workspace_timestamp ON monitoring_messages (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_sessions (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tmessage_count INTEGER NOT NULL, \n\tstart_time DATETIME NOT NULL, \n\tlast_activity DATETIME NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tplatform VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tuser_name VARCHAR(255), \n\tPRIMARY KEY (workspace_uuid, bot_id, session_id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_sessions_bot_id ON monitoring_sessions (bot_id)", + "CREATE INDEX ix_monitoring_sessions_is_active ON monitoring_sessions (is_active)", + "CREATE INDEX ix_monitoring_sessions_last_activity ON monitoring_sessions (last_activity)", + "CREATE INDEX ix_monitoring_sessions_pipeline_id ON monitoring_sessions (pipeline_id)", + "CREATE INDEX ix_monitoring_sessions_start_time ON monitoring_sessions (start_time)", + "CREATE INDEX ix_monitoring_sessions_workspace_active ON monitoring_sessions (workspace_uuid, is_active)", + "CREATE INDEX ix_monitoring_sessions_workspace_activity ON monitoring_sessions (workspace_uuid, last_activity)", + "\nCREATE TABLE monitoring_tool_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp DATETIME NOT NULL, \n\ttool_name VARCHAR(255) NOT NULL, \n\ttool_source VARCHAR(50) NOT NULL, \n\tduration INTEGER NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\targuments TEXT, \n\tresult TEXT, \n\terror_message TEXT, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_tool_calls_bot_id ON monitoring_tool_calls (bot_id)", + "CREATE INDEX ix_monitoring_tool_calls_message_id ON monitoring_tool_calls (message_id)", + "CREATE INDEX ix_monitoring_tool_calls_pipeline_id ON monitoring_tool_calls (pipeline_id)", + "CREATE INDEX ix_monitoring_tool_calls_session_id ON monitoring_tool_calls (session_id)", + "CREATE INDEX ix_monitoring_tool_calls_timestamp ON monitoring_tool_calls (timestamp)", + "CREATE INDEX ix_monitoring_tool_calls_workspace_session ON monitoring_tool_calls (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_tool_calls_workspace_timestamp ON monitoring_tool_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE plugin_settings (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tplugin_author VARCHAR(255) NOT NULL, \n\tplugin_name VARCHAR(255) NOT NULL, \n\tinstallation_uuid VARCHAR(36) NOT NULL, \n\tartifact_digest VARCHAR(64) NOT NULL, \n\truntime_revision INTEGER NOT NULL, \n\tenabled BOOLEAN NOT NULL, \n\tpriority INTEGER NOT NULL, \n\tconfig JSON NOT NULL, \n\tinstall_source VARCHAR(255) NOT NULL, \n\tinstall_info JSON NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (workspace_uuid, plugin_author, plugin_name), \n\tCONSTRAINT uq_plugin_settings_installation_uuid UNIQUE (installation_uuid), \n\tCONSTRAINT ck_plugin_settings_runtime_revision_positive CHECK (runtime_revision >= 1), \n\tCONSTRAINT ck_plugin_settings_artifact_digest_length CHECK (length(artifact_digest) = 64), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_plugin_settings_workspace_enabled ON plugin_settings (workspace_uuid, enabled)", + "CREATE UNIQUE INDEX ix_plugin_settings_workspace_installation ON plugin_settings (workspace_uuid, installation_uuid)", + "\nCREATE TABLE support_admin_temporary_sessions (\n\tgrant_jti_hash VARCHAR(64) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tactor_account_uuid VARCHAR(36) NOT NULL, \n\tissued_at DATETIME NOT NULL, \n\texpires_at DATETIME NOT NULL, \n\trevoked_at DATETIME, \n\tlast_used_at DATETIME, \n\tPRIMARY KEY (grant_jti_hash), \n\tCONSTRAINT ck_support_admin_sessions_grant_jti_hash CHECK (length(grant_jti_hash) = 64), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_support_admin_sessions_workspace_expiry ON support_admin_temporary_sessions (workspace_uuid, expires_at)", + "\nCREATE TABLE webhooks (\n\tid INTEGER NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\turl VARCHAR(1024) NOT NULL, \n\tdescription VARCHAR(512), \n\tenabled BOOLEAN NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_webhooks_workspace_name ON webhooks (workspace_uuid, name)", + "\nCREATE TABLE workspace_execution_states (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tactive_generation BIGINT DEFAULT '1' NOT NULL, \n\tstate VARCHAR(32) DEFAULT 'active' NOT NULL, \n\twrite_fenced BOOLEAN DEFAULT 0 NOT NULL, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tdesired_state_revision BIGINT DEFAULT '0' NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (workspace_uuid), \n\tCONSTRAINT ck_workspace_execution_generation CHECK (active_generation > 0), \n\tCONSTRAINT ck_workspace_execution_state CHECK (state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')), \n\tCONSTRAINT ck_workspace_execution_source CHECK (source IN ('local', 'cloud')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_workspace_execution_states_instance_state ON workspace_execution_states (instance_uuid, state)", + "\nCREATE TABLE workspace_invitations (\n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tnormalized_email VARCHAR(320) NOT NULL, \n\trole VARCHAR(32) NOT NULL, \n\ttoken_hash VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'pending' NOT NULL, \n\texpires_at DATETIME NOT NULL, \n\taccepted_at DATETIME, \n\trevoked_at DATETIME, \n\tcreated_by_account_uuid VARCHAR(36) NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT ck_workspace_invitations_role CHECK (role IN ('admin', 'developer', 'operator', 'viewer')), \n\tCONSTRAINT ck_workspace_invitations_status CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE UNIQUE INDEX uq_workspace_invitations_pending_email ON workspace_invitations (workspace_uuid, normalized_email) WHERE status = 'pending'", + "CREATE UNIQUE INDEX uq_workspace_invitations_token_hash ON workspace_invitations (token_hash)", + "\nCREATE TABLE workspace_memberships (\n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\taccount_uuid VARCHAR(36) NOT NULL, \n\trole VARCHAR(32) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tinvited_by_account_uuid VARCHAR(36), \n\tjoined_at DATETIME, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_workspace_membership_account UNIQUE (workspace_uuid, account_uuid), \n\tCONSTRAINT ck_workspace_memberships_role CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer')), \n\tCONSTRAINT ck_workspace_memberships_status CHECK (status IN ('active', 'disabled', 'removed')), \n\tCONSTRAINT ck_workspace_memberships_source CHECK (source IN ('local', 'cloud_projection')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(account_uuid) REFERENCES users (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(invited_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_workspace_memberships_account_status ON workspace_memberships (account_uuid, status)", + "CREATE UNIQUE INDEX uq_workspace_memberships_one_active_owner ON workspace_memberships (workspace_uuid) WHERE role = 'owner' AND status = 'active'", + "\nCREATE TABLE workspace_metadata (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\t\"key\" VARCHAR(255) NOT NULL, \n\tvalue VARCHAR(255), \n\tPRIMARY KEY (workspace_uuid, \"key\"), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "\nCREATE TABLE bot_admins (\n\tid INTEGER NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tbot_uuid VARCHAR(255) NOT NULL, \n\tlauncher_type VARCHAR(64) NOT NULL, \n\tlauncher_id VARCHAR(255) NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_bot_admin UNIQUE (workspace_uuid, bot_uuid, launcher_type, launcher_id), \n\tCONSTRAINT fk_bot_admins_workspace_bot FOREIGN KEY(workspace_uuid, bot_uuid) REFERENCES bots (workspace_uuid, uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "\nCREATE TABLE codex_credentials (\n\tprovider_uuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tpayload JSON NOT NULL, \n\tversion INTEGER NOT NULL, \n\tlease_owner VARCHAR(64), \n\tlease_until FLOAT NOT NULL, \n\tPRIMARY KEY (provider_uuid), \n\tCONSTRAINT fk_codex_credentials_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_codex_credentials_workspace ON codex_credentials (workspace_uuid)", + "\nCREATE TABLE embedding_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_embedding_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_embedding_models_workspace_name ON embedding_models (workspace_uuid, name)", + "CREATE INDEX ix_embedding_models_workspace_provider ON embedding_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE knowledge_base_files (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tkb_id VARCHAR(255), \n\tfile_name VARCHAR, \n\textension VARCHAR, \n\tcreated_at DATETIME, \n\tstatus VARCHAR, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_knowledge_base_files_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tCONSTRAINT fk_knowledge_base_files_workspace_kb FOREIGN KEY(workspace_uuid, kb_id) REFERENCES knowledge_bases (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_base_files_workspace_kb ON knowledge_base_files (workspace_uuid, kb_id)", + "\nCREATE TABLE llm_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\tabilities JSON NOT NULL, \n\tcontext_length INTEGER, \n\treasoning_config JSON DEFAULT '{\"level\":\"provider_default\"}' NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_llm_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_llm_models_workspace_name ON llm_models (workspace_uuid, name)", + "CREATE INDEX ix_llm_models_workspace_provider ON llm_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE pipeline_run_records (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tpipeline_uuid VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(255) NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tstarted_at DATETIME NOT NULL, \n\tfinished_at DATETIME NOT NULL, \n\tresult JSON NOT NULL, \n\tknowledge_base_uuid VARCHAR(255), \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_pipeline_run_records_workspace_pipeline FOREIGN KEY(workspace_uuid, pipeline_uuid) REFERENCES legacy_pipelines (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_pipeline_run_records_workspace_created ON pipeline_run_records (workspace_uuid, created_at)", + "CREATE INDEX ix_pipeline_run_records_workspace_pipeline ON pipeline_run_records (workspace_uuid, pipeline_uuid)", + "\nCREATE TABLE rerank_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_rerank_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_rerank_models_workspace_name ON rerank_models (workspace_uuid, name)", + "CREATE INDEX ix_rerank_models_workspace_provider ON rerank_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE knowledge_base_chunks (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tfile_id VARCHAR(255), \n\ttext TEXT, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_knowledge_base_chunks_workspace_file FOREIGN KEY(workspace_uuid, file_id) REFERENCES knowledge_base_files (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_base_chunks_workspace_file ON knowledge_base_chunks (workspace_uuid, file_id)" + ], + "postgresql": [ + "\nCREATE TABLE directory_projection_inbox (\n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tevent_uuid VARCHAR(36) NOT NULL, \n\tcursor BIGINT NOT NULL, \n\tevent_type VARCHAR(128) NOT NULL, \n\trevision BIGINT NOT NULL, \n\tfingerprint TEXT NOT NULL, \n\treceived_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL, \n\tapplied_at TIMESTAMP WITH TIME ZONE, \n\tPRIMARY KEY (instance_uuid, event_uuid), \n\tCONSTRAINT uq_directory_projection_inbox_cursor UNIQUE (instance_uuid, cursor), \n\tCONSTRAINT ck_directory_projection_inbox_cursor CHECK (cursor > 0), \n\tCONSTRAINT ck_directory_projection_inbox_revision CHECK (revision > 0), \n\tCONSTRAINT ck_directory_projection_inbox_fingerprint CHECK (length(fingerprint) = 64)\n)\n\n", + "CREATE INDEX ix_directory_projection_inbox_pending ON directory_projection_inbox (instance_uuid, applied_at, cursor)", + "\nCREATE TABLE directory_projection_states (\n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tcursor BIGINT DEFAULT '0' NOT NULL, \n\tsnapshot_coverage_cursor BIGINT DEFAULT '0' NOT NULL, \n\tsnapshot_fingerprint TEXT NOT NULL, \n\tlast_applied_at TIMESTAMP WITH TIME ZONE NOT NULL, \n\tlease_expires_at TIMESTAMP WITH TIME ZONE, \n\tPRIMARY KEY (instance_uuid), \n\tCONSTRAINT ck_directory_projection_state_cursor CHECK (cursor >= 0), \n\tCONSTRAINT ck_directory_projection_state_snapshot_coverage CHECK (snapshot_coverage_cursor >= 0 AND snapshot_coverage_cursor <= cursor), \n\tCONSTRAINT ck_directory_projection_state_fingerprint CHECK (length(snapshot_fingerprint) = 64)\n)\n\n", + "\nCREATE TABLE metadata (\n\tkey VARCHAR(255) NOT NULL, \n\tvalue VARCHAR(255), \n\tPRIMARY KEY (key)\n)\n\n", + "\nCREATE TABLE users (\n\tid SERIAL NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\t\"user\" VARCHAR(255) NOT NULL, \n\tnormalized_email VARCHAR(320) NOT NULL, \n\tpassword VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\taccount_type VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tspace_account_uuid VARCHAR(255), \n\tspace_access_token TEXT, \n\tspace_refresh_token TEXT, \n\tspace_access_token_expires_at TIMESTAMP WITHOUT TIME ZONE, \n\tspace_api_key VARCHAR(255), \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT ck_users_normalized_email CHECK (normalized_email = trim(normalized_email) AND length(normalized_email) > 0 AND length(normalized_email) <= 320), \n\tCONSTRAINT ck_users_status CHECK (status IN ('active', 'disabled', 'deleted')), \n\tCONSTRAINT ck_users_source CHECK (source IN ('local', 'cloud_projection'))\n)\n\n", + "CREATE UNIQUE INDEX uq_users_normalized_email ON users (normalized_email)", + "CREATE UNIQUE INDEX uq_users_uuid ON users (uuid)", + "\nCREATE TABLE workspaces (\n\tuuid VARCHAR(36) NOT NULL, \n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tslug VARCHAR(255) NOT NULL, \n\ttype VARCHAR(32) DEFAULT 'team' NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tcreated_by_account_uuid VARCHAR(36), \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_workspaces_instance_slug UNIQUE (instance_uuid, slug), \n\tCONSTRAINT ck_workspaces_type CHECK (type IN ('personal', 'team')), \n\tCONSTRAINT ck_workspaces_status CHECK (status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')), \n\tCONSTRAINT ck_workspaces_source CHECK (source IN ('local', 'cloud_projection')), \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_workspaces_instance_status ON workspaces (instance_uuid, status)", + "CREATE UNIQUE INDEX uq_workspaces_local_instance ON workspaces (instance_uuid) WHERE source = 'local'", + "\nCREATE TABLE api_keys (\n\tid SERIAL NOT NULL, \n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tcreated_by_account_uuid VARCHAR(36), \n\tname VARCHAR(255) NOT NULL, \n\tkey_hash VARCHAR(64) NOT NULL, \n\tscopes JSON DEFAULT '[]' NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\texpires_at TIMESTAMP WITHOUT TIME ZONE, \n\tlast_used_at TIMESTAMP WITHOUT TIME ZONE, \n\tdescription VARCHAR(512), \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT ck_api_keys_status CHECK (status IN ('active', 'revoked')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_api_keys_workspace_name ON api_keys (workspace_uuid, name)", + "CREATE INDEX ix_api_keys_workspace_status ON api_keys (workspace_uuid, status)", + "CREATE UNIQUE INDEX uq_api_keys_key_hash ON api_keys (key_hash)", + "CREATE UNIQUE INDEX uq_api_keys_uuid ON api_keys (uuid)", + "\nCREATE TABLE binary_storages (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tunique_key VARCHAR(255) NOT NULL, \n\tkey VARCHAR(255) NOT NULL, \n\towner_type VARCHAR(255) NOT NULL, \n\towner VARCHAR(255) NOT NULL, \n\tvalue BYTEA NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (workspace_uuid, unique_key), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_binary_storages_workspace_owner ON binary_storages (workspace_uuid, owner_type, owner)", + "\nCREATE TABLE bots (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tdescription VARCHAR(255) NOT NULL, \n\tadapter VARCHAR(255) NOT NULL, \n\tadapter_config JSON NOT NULL, \n\tenable BOOLEAN NOT NULL, \n\tuse_pipeline_name VARCHAR(255), \n\tuse_pipeline_uuid VARCHAR(255), \n\tpipeline_routing_rules JSON DEFAULT '[]' NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_bots_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_bots_workspace_name ON bots (workspace_uuid, name)", + "\nCREATE TABLE knowledge_bases (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR, \n\tdescription TEXT, \n\temoji VARCHAR(10), \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE, \n\tknowledge_engine_plugin_id VARCHAR, \n\tcollection_id VARCHAR, \n\tlegacy_vector_collection BOOLEAN DEFAULT false NOT NULL, \n\tcreation_settings JSON, \n\tretrieval_settings JSON, \n\tembedding_dimension INTEGER, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_knowledge_bases_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tCONSTRAINT ck_knowledge_bases_embedding_dimension_positive CHECK (embedding_dimension IS NULL OR embedding_dimension > 0), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_bases_name ON knowledge_bases (name)", + "CREATE INDEX ix_knowledge_bases_workspace_name ON knowledge_bases (workspace_uuid, name)", + "CREATE UNIQUE INDEX uq_knowledge_bases_workspace_collection ON knowledge_bases (workspace_uuid, collection_id) WHERE collection_id IS NOT NULL", + "\nCREATE TABLE legacy_pipelines (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tdescription VARCHAR(255) NOT NULL, \n\temoji VARCHAR(10), \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tfor_version VARCHAR(255) NOT NULL, \n\tis_default BOOLEAN NOT NULL, \n\tstages JSON NOT NULL, \n\tconfig JSON NOT NULL, \n\textensions_preferences JSON NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_legacy_pipelines_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_legacy_pipelines_workspace_default ON legacy_pipelines (workspace_uuid, is_default)", + "CREATE INDEX ix_legacy_pipelines_workspace_name ON legacy_pipelines (workspace_uuid, name)", + "\nCREATE TABLE mcp_servers (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tenable BOOLEAN NOT NULL, \n\tmode VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\treadme TEXT DEFAULT '' NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_mcp_servers_workspace_name UNIQUE (workspace_uuid, name), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_mcp_servers_workspace_enable ON mcp_servers (workspace_uuid, enable)", + "\nCREATE TABLE model_providers (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\trequester VARCHAR(255) NOT NULL, \n\tbase_url VARCHAR(512) NOT NULL, \n\tapi_keys JSON NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_model_providers_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_model_providers_workspace_name ON model_providers (workspace_uuid, name)", + "CREATE INDEX ix_model_providers_workspace_requester ON model_providers (workspace_uuid, requester)", + "\nCREATE TABLE monitoring_embedding_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tmodel_name VARCHAR(255) NOT NULL, \n\tprompt_tokens INTEGER NOT NULL, \n\ttotal_tokens INTEGER NOT NULL, \n\tduration INTEGER NOT NULL, \n\tinput_count INTEGER NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\terror_message TEXT, \n\tknowledge_base_id VARCHAR(255), \n\tquery_text TEXT, \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\tcall_type VARCHAR(50), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_embedding_calls_knowledge_base_id ON monitoring_embedding_calls (knowledge_base_id)", + "CREATE INDEX ix_monitoring_embedding_calls_message_id ON monitoring_embedding_calls (message_id)", + "CREATE INDEX ix_monitoring_embedding_calls_session_id ON monitoring_embedding_calls (session_id)", + "CREATE INDEX ix_monitoring_embedding_calls_timestamp ON monitoring_embedding_calls (timestamp)", + "CREATE INDEX ix_monitoring_embedding_calls_workspace_kb ON monitoring_embedding_calls (workspace_uuid, knowledge_base_id)", + "CREATE INDEX ix_monitoring_embedding_calls_workspace_timestamp ON monitoring_embedding_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_errors (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\terror_type VARCHAR(255) NOT NULL, \n\terror_message TEXT NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255), \n\tstack_trace TEXT, \n\tmessage_id VARCHAR(255), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_errors_bot_id ON monitoring_errors (bot_id)", + "CREATE INDEX ix_monitoring_errors_message_id ON monitoring_errors (message_id)", + "CREATE INDEX ix_monitoring_errors_pipeline_id ON monitoring_errors (pipeline_id)", + "CREATE INDEX ix_monitoring_errors_timestamp ON monitoring_errors (timestamp)", + "CREATE INDEX ix_monitoring_errors_workspace_session ON monitoring_errors (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_errors_workspace_timestamp ON monitoring_errors (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_feedback (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tfeedback_id VARCHAR(255) NOT NULL, \n\tfeedback_type INTEGER NOT NULL, \n\tfeedback_content TEXT, \n\tinaccurate_reasons TEXT, \n\tbot_id VARCHAR(255), \n\tbot_name VARCHAR(255), \n\tpipeline_id VARCHAR(255), \n\tpipeline_name VARCHAR(255), \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\tstream_id VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tplatform VARCHAR(255), \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_monitoring_feedback_workspace_feedback_id UNIQUE (workspace_uuid, feedback_id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_feedback_bot_id ON monitoring_feedback (bot_id)", + "CREATE INDEX ix_monitoring_feedback_feedback_id ON monitoring_feedback (feedback_id)", + "CREATE INDEX ix_monitoring_feedback_message_id ON monitoring_feedback (message_id)", + "CREATE INDEX ix_monitoring_feedback_pipeline_id ON monitoring_feedback (pipeline_id)", + "CREATE INDEX ix_monitoring_feedback_session_id ON monitoring_feedback (session_id)", + "CREATE INDEX ix_monitoring_feedback_stream_id ON monitoring_feedback (stream_id)", + "CREATE INDEX ix_monitoring_feedback_timestamp ON monitoring_feedback (timestamp)", + "CREATE INDEX ix_monitoring_feedback_workspace_session ON monitoring_feedback (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_feedback_workspace_timestamp ON monitoring_feedback (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_llm_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tmodel_name VARCHAR(255) NOT NULL, \n\tinput_tokens INTEGER NOT NULL, \n\toutput_tokens INTEGER NOT NULL, \n\ttotal_tokens INTEGER NOT NULL, \n\tduration INTEGER NOT NULL, \n\tcost FLOAT, \n\tstatus VARCHAR(50) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\terror_message TEXT, \n\tmessage_id VARCHAR(255), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_llm_calls_bot_id ON monitoring_llm_calls (bot_id)", + "CREATE INDEX ix_monitoring_llm_calls_message_id ON monitoring_llm_calls (message_id)", + "CREATE INDEX ix_monitoring_llm_calls_pipeline_id ON monitoring_llm_calls (pipeline_id)", + "CREATE INDEX ix_monitoring_llm_calls_timestamp ON monitoring_llm_calls (timestamp)", + "CREATE INDEX ix_monitoring_llm_calls_workspace_session ON monitoring_llm_calls (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_llm_calls_workspace_timestamp ON monitoring_llm_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_messages (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tmessage_content TEXT NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\tlevel VARCHAR(50) NOT NULL, \n\tplatform VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tuser_name VARCHAR(255), \n\trunner_name VARCHAR(255), \n\tvariables TEXT, \n\trole VARCHAR(50), \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_messages_bot_id ON monitoring_messages (bot_id)", + "CREATE INDEX ix_monitoring_messages_pipeline_id ON monitoring_messages (pipeline_id)", + "CREATE INDEX ix_monitoring_messages_session_id ON monitoring_messages (session_id)", + "CREATE INDEX ix_monitoring_messages_timestamp ON monitoring_messages (timestamp)", + "CREATE INDEX ix_monitoring_messages_workspace_session ON monitoring_messages (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_messages_workspace_timestamp ON monitoring_messages (workspace_uuid, timestamp)", + "\nCREATE TABLE monitoring_sessions (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tmessage_count INTEGER NOT NULL, \n\tstart_time TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tlast_activity TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tis_active BOOLEAN NOT NULL, \n\tplatform VARCHAR(255), \n\tuser_id VARCHAR(255), \n\tuser_name VARCHAR(255), \n\tPRIMARY KEY (workspace_uuid, bot_id, session_id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_sessions_bot_id ON monitoring_sessions (bot_id)", + "CREATE INDEX ix_monitoring_sessions_is_active ON monitoring_sessions (is_active)", + "CREATE INDEX ix_monitoring_sessions_last_activity ON monitoring_sessions (last_activity)", + "CREATE INDEX ix_monitoring_sessions_pipeline_id ON monitoring_sessions (pipeline_id)", + "CREATE INDEX ix_monitoring_sessions_start_time ON monitoring_sessions (start_time)", + "CREATE INDEX ix_monitoring_sessions_workspace_active ON monitoring_sessions (workspace_uuid, is_active)", + "CREATE INDEX ix_monitoring_sessions_workspace_activity ON monitoring_sessions (workspace_uuid, last_activity)", + "\nCREATE TABLE monitoring_tool_calls (\n\tid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\ttimestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\ttool_name VARCHAR(255) NOT NULL, \n\ttool_source VARCHAR(50) NOT NULL, \n\tduration INTEGER NOT NULL, \n\tstatus VARCHAR(50) NOT NULL, \n\tbot_id VARCHAR(255) NOT NULL, \n\tbot_name VARCHAR(255) NOT NULL, \n\tpipeline_id VARCHAR(255) NOT NULL, \n\tpipeline_name VARCHAR(255) NOT NULL, \n\tsession_id VARCHAR(255), \n\tmessage_id VARCHAR(255), \n\targuments TEXT, \n\tresult TEXT, \n\terror_message TEXT, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_monitoring_tool_calls_bot_id ON monitoring_tool_calls (bot_id)", + "CREATE INDEX ix_monitoring_tool_calls_message_id ON monitoring_tool_calls (message_id)", + "CREATE INDEX ix_monitoring_tool_calls_pipeline_id ON monitoring_tool_calls (pipeline_id)", + "CREATE INDEX ix_monitoring_tool_calls_session_id ON monitoring_tool_calls (session_id)", + "CREATE INDEX ix_monitoring_tool_calls_timestamp ON monitoring_tool_calls (timestamp)", + "CREATE INDEX ix_monitoring_tool_calls_workspace_session ON monitoring_tool_calls (workspace_uuid, session_id)", + "CREATE INDEX ix_monitoring_tool_calls_workspace_timestamp ON monitoring_tool_calls (workspace_uuid, timestamp)", + "\nCREATE TABLE plugin_settings (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tplugin_author VARCHAR(255) NOT NULL, \n\tplugin_name VARCHAR(255) NOT NULL, \n\tinstallation_uuid VARCHAR(36) NOT NULL, \n\tartifact_digest VARCHAR(64) NOT NULL, \n\truntime_revision INTEGER NOT NULL, \n\tenabled BOOLEAN NOT NULL, \n\tpriority INTEGER NOT NULL, \n\tconfig JSON NOT NULL, \n\tinstall_source VARCHAR(255) NOT NULL, \n\tinstall_info JSON NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (workspace_uuid, plugin_author, plugin_name), \n\tCONSTRAINT uq_plugin_settings_installation_uuid UNIQUE (installation_uuid), \n\tCONSTRAINT ck_plugin_settings_runtime_revision_positive CHECK (runtime_revision >= 1), \n\tCONSTRAINT ck_plugin_settings_artifact_digest_length CHECK (length(artifact_digest) = 64), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_plugin_settings_workspace_enabled ON plugin_settings (workspace_uuid, enabled)", + "CREATE UNIQUE INDEX ix_plugin_settings_workspace_installation ON plugin_settings (workspace_uuid, installation_uuid)", + "\nCREATE TABLE support_admin_temporary_sessions (\n\tgrant_jti_hash VARCHAR(64) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tactor_account_uuid VARCHAR(36) NOT NULL, \n\tissued_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\texpires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\trevoked_at TIMESTAMP WITHOUT TIME ZONE, \n\tlast_used_at TIMESTAMP WITHOUT TIME ZONE, \n\tPRIMARY KEY (grant_jti_hash), \n\tCONSTRAINT ck_support_admin_sessions_grant_jti_hash CHECK (length(grant_jti_hash) = 64), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_support_admin_sessions_workspace_expiry ON support_admin_temporary_sessions (workspace_uuid, expires_at)", + "\nCREATE TABLE webhooks (\n\tid SERIAL NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\turl VARCHAR(1024) NOT NULL, \n\tdescription VARCHAR(512), \n\tenabled BOOLEAN NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (id), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_webhooks_workspace_name ON webhooks (workspace_uuid, name)", + "\nCREATE TABLE workspace_execution_states (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tinstance_uuid VARCHAR(255) NOT NULL, \n\tactive_generation BIGINT DEFAULT '1' NOT NULL, \n\tstate VARCHAR(32) DEFAULT 'active' NOT NULL, \n\twrite_fenced BOOLEAN DEFAULT false NOT NULL, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tdesired_state_revision BIGINT DEFAULT '0' NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (workspace_uuid), \n\tCONSTRAINT ck_workspace_execution_generation CHECK (active_generation > 0), \n\tCONSTRAINT ck_workspace_execution_state CHECK (state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')), \n\tCONSTRAINT ck_workspace_execution_source CHECK (source IN ('local', 'cloud')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_workspace_execution_states_instance_state ON workspace_execution_states (instance_uuid, state)", + "\nCREATE TABLE workspace_invitations (\n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tnormalized_email VARCHAR(320) NOT NULL, \n\trole VARCHAR(32) NOT NULL, \n\ttoken_hash VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'pending' NOT NULL, \n\texpires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\taccepted_at TIMESTAMP WITHOUT TIME ZONE, \n\trevoked_at TIMESTAMP WITHOUT TIME ZONE, \n\tcreated_by_account_uuid VARCHAR(36) NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT ck_workspace_invitations_role CHECK (role IN ('admin', 'developer', 'operator', 'viewer')), \n\tCONSTRAINT ck_workspace_invitations_status CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(created_by_account_uuid) REFERENCES users (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE UNIQUE INDEX uq_workspace_invitations_pending_email ON workspace_invitations (workspace_uuid, normalized_email) WHERE status = 'pending'", + "CREATE UNIQUE INDEX uq_workspace_invitations_token_hash ON workspace_invitations (token_hash)", + "\nCREATE TABLE workspace_memberships (\n\tuuid VARCHAR(36) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\taccount_uuid VARCHAR(36) NOT NULL, \n\trole VARCHAR(32) NOT NULL, \n\tstatus VARCHAR(32) DEFAULT 'active' NOT NULL, \n\tinvited_by_account_uuid VARCHAR(36), \n\tjoined_at TIMESTAMP WITHOUT TIME ZONE, \n\tsource VARCHAR(32) DEFAULT 'local' NOT NULL, \n\tprojection_revision BIGINT DEFAULT '0' NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_workspace_membership_account UNIQUE (workspace_uuid, account_uuid), \n\tCONSTRAINT ck_workspace_memberships_role CHECK (role IN ('owner', 'admin', 'developer', 'operator', 'viewer')), \n\tCONSTRAINT ck_workspace_memberships_status CHECK (status IN ('active', 'disabled', 'removed')), \n\tCONSTRAINT ck_workspace_memberships_source CHECK (source IN ('local', 'cloud_projection')), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(account_uuid) REFERENCES users (uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(invited_by_account_uuid) REFERENCES users (uuid) ON DELETE SET NULL\n)\n\n", + "CREATE INDEX ix_workspace_memberships_account_status ON workspace_memberships (account_uuid, status)", + "CREATE UNIQUE INDEX uq_workspace_memberships_one_active_owner ON workspace_memberships (workspace_uuid) WHERE role = 'owner' AND status = 'active'", + "\nCREATE TABLE workspace_metadata (\n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tkey VARCHAR(255) NOT NULL, \n\tvalue VARCHAR(255), \n\tPRIMARY KEY (workspace_uuid, key), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "\nCREATE TABLE bot_admins (\n\tid SERIAL NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tbot_uuid VARCHAR(255) NOT NULL, \n\tlauncher_type VARCHAR(64) NOT NULL, \n\tlauncher_id VARCHAR(255) NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_bot_admin UNIQUE (workspace_uuid, bot_uuid, launcher_type, launcher_id), \n\tCONSTRAINT fk_bot_admins_workspace_bot FOREIGN KEY(workspace_uuid, bot_uuid) REFERENCES bots (workspace_uuid, uuid) ON DELETE CASCADE, \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "\nCREATE TABLE codex_credentials (\n\tprovider_uuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tpayload JSON NOT NULL, \n\tversion INTEGER NOT NULL, \n\tlease_owner VARCHAR(64), \n\tlease_until FLOAT NOT NULL, \n\tPRIMARY KEY (provider_uuid), \n\tCONSTRAINT fk_codex_credentials_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_codex_credentials_workspace ON codex_credentials (workspace_uuid)", + "\nCREATE TABLE embedding_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_embedding_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_embedding_models_workspace_name ON embedding_models (workspace_uuid, name)", + "CREATE INDEX ix_embedding_models_workspace_provider ON embedding_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE knowledge_base_files (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tkb_id VARCHAR(255), \n\tfile_name VARCHAR, \n\textension VARCHAR, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE, \n\tstatus VARCHAR, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT uq_knowledge_base_files_workspace_uuid UNIQUE (workspace_uuid, uuid), \n\tCONSTRAINT fk_knowledge_base_files_workspace_kb FOREIGN KEY(workspace_uuid, kb_id) REFERENCES knowledge_bases (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_base_files_workspace_kb ON knowledge_base_files (workspace_uuid, kb_id)", + "\nCREATE TABLE llm_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\tabilities JSON NOT NULL, \n\tcontext_length INTEGER, \n\treasoning_config JSON DEFAULT '{\"level\":\"provider_default\"}' NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_llm_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_llm_models_workspace_name ON llm_models (workspace_uuid, name)", + "CREATE INDEX ix_llm_models_workspace_provider ON llm_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE pipeline_run_records (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tpipeline_uuid VARCHAR(255) NOT NULL, \n\tstatus VARCHAR(255) NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tstarted_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tfinished_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, \n\tresult JSON NOT NULL, \n\tknowledge_base_uuid VARCHAR(255), \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_pipeline_run_records_workspace_pipeline FOREIGN KEY(workspace_uuid, pipeline_uuid) REFERENCES legacy_pipelines (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_pipeline_run_records_workspace_created ON pipeline_run_records (workspace_uuid, created_at)", + "CREATE INDEX ix_pipeline_run_records_workspace_pipeline ON pipeline_run_records (workspace_uuid, pipeline_uuid)", + "\nCREATE TABLE rerank_models (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tname VARCHAR(255) NOT NULL, \n\tprovider_uuid VARCHAR(255) NOT NULL, \n\textra_args JSON NOT NULL, \n\tprefered_ranking INTEGER NOT NULL, \n\tcreated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tupdated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_rerank_models_workspace_provider FOREIGN KEY(workspace_uuid, provider_uuid) REFERENCES model_providers (workspace_uuid, uuid), \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_rerank_models_workspace_name ON rerank_models (workspace_uuid, name)", + "CREATE INDEX ix_rerank_models_workspace_provider ON rerank_models (workspace_uuid, provider_uuid)", + "\nCREATE TABLE knowledge_base_chunks (\n\tuuid VARCHAR(255) NOT NULL, \n\tworkspace_uuid VARCHAR(36) NOT NULL, \n\tfile_id VARCHAR(255), \n\ttext TEXT, \n\tPRIMARY KEY (uuid), \n\tCONSTRAINT fk_knowledge_base_chunks_workspace_file FOREIGN KEY(workspace_uuid, file_id) REFERENCES knowledge_base_files (workspace_uuid, uuid) ON DELETE CASCADE, \n\tUNIQUE (uuid), \n\tFOREIGN KEY(workspace_uuid) REFERENCES workspaces (uuid) ON DELETE CASCADE\n)\n\n", + "CREATE INDEX ix_knowledge_base_chunks_workspace_file ON knowledge_base_chunks (workspace_uuid, file_id)" + ] + } +} diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py index c82742acf..47bac85fe 100644 --- a/tests/integration/persistence/resource_migration_support.py +++ b/tests/integration/persistence/resource_migration_support.py @@ -202,6 +202,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None: table.append_column(sa.Column(name, sa.String(255))) if table_name == 'monitoring_messages': table.append_column(sa.Column('bot_id', sa.String(255), nullable=False, default='bot-1')) + table.append_column(sa.Column('status', sa.String(50), nullable=False, default='success')) table.append_column(sa.Column('role', sa.String(50))) else: table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1)) diff --git a/tests/integration/persistence/test_codex_auth.py b/tests/integration/persistence/test_codex_auth.py index f0aae879a..5ce5662a0 100644 --- a/tests/integration/persistence/test_codex_auth.py +++ b/tests/integration/persistence/test_codex_auth.py @@ -298,12 +298,13 @@ async def test_device_replacement_expiry_and_idempotent_cancel(auth): async def test_device_accepts_issuer_iso_expiry(auth): from datetime import datetime, timezone - expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z') + expected_expiry = int(time.time()) + 600 + expires = datetime.fromtimestamp(expected_expiry, timezone.utc).isoformat().replace('+00:00', 'Z') auth._post = AsyncMock( return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires}) ) result = await auth.start(context(), 'p') - assert time.time() < result['expires_at'] < time.time() + 900 + assert result['expires_at'] == expected_expiry @pytest.mark.asyncio diff --git a/tests/integration/persistence/test_media_reference_postgres.py b/tests/integration/persistence/test_media_reference_postgres.py new file mode 100644 index 000000000..a9bea397c --- /dev/null +++ b/tests/integration/persistence/test_media_reference_postgres.py @@ -0,0 +1,190 @@ +"""Media writes against real asyncpg and existing TEXT columns. + +Run with TEST_POSTGRES_URL pointing to a disposable PostgreSQL database. +Each case creates and removes its own schema. +""" + +import importlib +import json +import os +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from langbot.pkg.agent.runner.run_journal import AgentRunJournal +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.persistence.tenant_uow import _validate_scoped_statement_call +from langbot.pkg.storage.media import MediaCache + +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] + + +def _published_schema(connection): + with Operations.context(MigrationContext.configure(connection)): + for revision in ( + '58846a8d7a81_add_event_log_and_transcript_tables', + '8d3a1f2c4b6e_add_agent_run_ledger', + '0036_execution_host_leases', + ): + importlib.import_module(f'langbot.pkg.persistence.alembic.versions.{revision}').upgrade() + + +@pytest.fixture(params=['metadata', 'published_migrations']) +async def pg_engine(request): + url = os.environ.get('TEST_POSTGRES_URL') + if not url: + pytest.skip('TEST_POSTGRES_URL is required (disposable PostgreSQL)') + namespace = f'media_contract_{uuid.uuid4().hex}' + admin = create_async_engine(url) + engine = None + try: + async with admin.begin() as conn: + await conn.execute(sa.text(f'CREATE SCHEMA {namespace}')) + engine = create_async_engine(url, connect_args={'server_settings': {'search_path': namespace}}) + async with engine.begin() as conn: + if request.param == 'metadata': + await conn.run_sync( + lambda sync: Base.metadata.create_all(sync, tables=[AgentRun.__table__, AgentRunEvent.__table__]) + ) + else: + await conn.run_sync(_published_schema) + yield engine + finally: + if engine is not None: + await engine.dispose() + async with admin.begin() as conn: + await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS {namespace} CASCADE')) + await admin.dispose() + + +@pytest.fixture +async def store(pg_engine): + store = RunLedgerStore(pg_engine) + await store.create_run( + run_id='run', event_id='event', binding_id=None, runner_id='runner', workspace_id='ws', metadata={'attempt': 0} + ) + return store + + +@pytest.mark.parametrize('writer', ['orm', 'insert', 'update']) +@pytest.mark.parametrize( + 'image', + [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}}, + ], +) +async def test_unprocessed_images_roll_back(pg_engine, store, writer, image): + content = json.dumps({'nested': [image]}) + with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'): + if writer == 'orm': + async with AsyncSession(pg_engine) as session: + run = (await session.execute(sa.select(AgentRun).where(AgentRun.run_id == 'run'))).scalar_one() + run.status = 'completed' + run.metadata_json = content + await session.commit() + else: + statement = ( + sa.insert(AgentRun).values(run_id='new', runner_id='runner', status='completed', metadata_json=content) + if writer == 'insert' + else sa.update(AgentRun) + .where(AgentRun.run_id == 'run') + .values(status='completed', metadata_json=content) + ) + async with pg_engine.begin() as conn: + await conn.execute(statement) + run = await store.get_run('run') + assert run['status'] == 'running' + assert run['metadata'] == {'attempt': 0} + assert await store.get_run('new') is None + + +@pytest.mark.parametrize('operation', ['pin', 'finalize', 'status_only']) +async def test_legacy_metadata_updates_preserve_the_write_contract(pg_engine, store, operation): + legacy = {'image': {'type': 'Image', 'base64': 'eA=='}, 'attempt': 0} + original = json.dumps(legacy) + async with pg_engine.begin() as conn: + # Seed the pre-contract format while keeping current model mappings. + await conn.execute( + sa.update(AgentRun) + .where(AgentRun.run_id == 'run') + .values(metadata_json=sa.bindparam('legacy', original, type_=sa.Text())) + ) + assert ( + await conn.execute(sa.select(AgentRun.run_id).where(AgentRun.metadata_json == original)) + ).scalar_one() == 'run' + + with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'): + await store.finalize_run( + run_id='run', status='completed', metadata={'new': {'type': 'Image', 'base64': 'eQ=='}} + ) + assert (await store.get_run('run'))['status'] == 'running' + assert (await store.get_run('run'))['metadata'] == legacy + + if operation == 'pin': + assert await store.pin_skill_revision('run', 'ws', 'demo', 'v1') == 'v1' + assert (await store.get_run('run'))['metadata'] == { + 'image': {'type': 'Image', 'base64': None}, + 'attempt': 0, + 'activated_skills': {'demo': 'v1'}, + } + else: + metadata = {'note': 'done'} if operation == 'finalize' else None + run = await store.finalize_run(run_id='run', status='completed', metadata=metadata) + assert run['status'] == 'completed' + assert run['metadata'] == ( + {'image': {'type': 'Image', 'base64': None}, 'attempt': 0, 'note': 'done'} + if metadata is not None + else legacy + ) + + +async def test_journal_retains_references_and_redacts_metadata(pg_engine, store): + provider = SimpleNamespace(exists=AsyncMock(return_value=False), save=AsyncMock()) + storage = SimpleNamespace(storage_provider=provider) + app = SimpleNamespace(storage_mgr=storage, persistence_mgr=SimpleNamespace(get_db_engine=lambda: pg_engine)) + storage.media_cache = MediaCache(app, storage) + image = {'type': 'Image', 'base64': 'eA=='} + event = await AgentRunJournal(app).append_run_result( + run_id='run', + sequence=1, + result_dict={'type': 'tool.call.completed', 'data': {'image': image}}, + metadata={'preview': image}, + ) + assert event['data']['image']['base64'] is None + assert event['data']['image']['url'].startswith('/api/v1/files/media/') + assert event['metadata'] == {'preview': {'type': 'Image', 'base64': None}} + provider.save.assert_awaited_once() + async with pg_engine.connect() as conn: + row = (await conn.execute(sa.select(AgentRunEvent.data_json, AgentRunEvent.metadata_json))).one() + assert json.loads(row.data_json) == event['data'] + assert json.loads(row.metadata_json) == event['metadata'] + + +async def test_text_schema_and_monitoring_json_casts(pg_engine, store): + statement = sa.select(sa.cast(AgentRun.metadata_json, sa.JSON)['attempt'].as_integer()) + _validate_scoped_statement_call((statement,), {}) + async with pg_engine.connect() as conn: + assert (await conn.execute(statement)).scalar_one() == 0 + columns = ( + ( + await conn.execute( + sa.text( + 'SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() ' + "AND table_name IN ('agent_run', 'agent_run_event') AND column_name IN ('data_json', 'metadata_json')" + ) + ) + ) + .scalars() + .all() + ) + assert columns == ['text'] * 3 diff --git a/tests/integration/persistence/test_migration_branch_convergence.py b/tests/integration/persistence/test_migration_branch_convergence.py index 6020c9b37..58e4a5796 100644 --- a/tests/integration/persistence/test_migration_branch_convergence.py +++ b/tests/integration/persistence/test_migration_branch_convergence.py @@ -39,7 +39,7 @@ ACCOUNT = '41100000-0000-4000-8000-000000000002' NOW = datetime.datetime(2026, 9, 1) -@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=pytest.mark.slow)]) +@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=[pytest.mark.slow, pytest.mark.postgres])]) async def convergence_engine(request, tmp_path): if request.param == 'sqlite': engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "convergence.db"}') @@ -108,6 +108,18 @@ async def _seed_common(engine): base_url='https://fixture.invalid', api_keys=['fixture-key'], ) + await _insert( + conn, + 'llm_models', + uuid='model-1', + workspace_uuid=WORKSPACE, + name='Fixture model', + provider_uuid='provider-1', + abilities=['vision'], + extra_args={'sentinel': 'preserve-model'}, + prefered_ranking=7, + reasoning_config={'level': 'provider_default'}, + ) await _insert( conn, 'legacy_pipelines', @@ -261,6 +273,31 @@ async def _assert_merged_schema_and_data(engine, source): assert await conn.scalar(sa.text('SELECT password FROM users')) == 'hash' assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships')) == 1 assert await conn.scalar(sa.text('SELECT name FROM model_providers')) == 'Fixture provider' + models = await conn.run_sync(lambda sync: sa.Table('llm_models', sa.MetaData(), autoload_with=sync)) + model = ( + await conn.execute( + sa.select( + models.c.name, + models.c.abilities, + models.c.extra_args, + models.c.prefered_ranking, + models.c.reasoning_config, + ) + ) + ).one() + assert tuple(model) == ( + 'Fixture model', + ['vision'], + {'sentinel': 'preserve-model'}, + 7, + {'level': 'provider_default'}, + ) + reasoning_column = await conn.run_sync( + lambda sync: next( + column for column in sa.inspect(sync).get_columns('llm_models') if column['name'] == 'reasoning_config' + ) + ) + assert reasoning_column['nullable'] is False assert await conn.scalar(sa.text('SELECT description FROM legacy_pipelines')) == 'preserve pipeline' assert ( await conn.execute(sa.text('SELECT message_content FROM monitoring_messages ORDER BY id')) @@ -306,8 +343,19 @@ async def _assert_merged_schema_and_data(engine, source): @pytest.mark.asyncio -@pytest.mark.parametrize('source', ['baseline', 'master', 'beta']) -async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source): +@pytest.mark.parametrize( + ('source', 'checkpoint'), + [ + ('baseline', None), + ('baseline', '0016_space_launch_replay'), + ('baseline', '0018_merge_workspace_heads'), + ('baseline', '0018_llm_reasoning_config'), + ('master', None), + ('beta', None), + ], +) +@pytest.mark.contract('migration_history') +async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source, checkpoint): engine = convergence_engine fixture = json.loads((FIXTURES / f'{source}_schema.json').read_text()) async with engine.begin() as conn: @@ -338,6 +386,9 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine await run_alembic_upgrade(engine, '0010_scope_resources') async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + if checkpoint is not None: + await run_alembic_upgrade(engine, checkpoint) + assert await get_alembic_current(engine) == checkpoint await run_alembic_upgrade(engine, 'head') await _assert_merged_schema_and_data(engine, source) await run_alembic_upgrade(engine, 'head') @@ -345,12 +396,37 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine @pytest.mark.asyncio +@pytest.mark.contract('migration_fresh_schema') async def test_empty_database_startup_schema_then_real_migrations(convergence_engine): engine = convergence_engine async with engine.begin() as conn: assert await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) == [] # This is the documented fresh-install contract: create_all precedes Alembic. await conn.run_sync(Base.metadata.create_all) + await conn.execute( + Base.metadata.tables['metadata'].insert().values(key='instance_uuid', value='migration-convergence-fresh') + ) + await conn.execute( + Base.metadata.tables['users'] + .insert() + .values( + uuid=ACCOUNT, user='fresh@example.com', normalized_email='fresh@example.com', password='fresh-password' + ) + ) + await run_alembic_upgrade(engine, 'head') + assert await get_alembic_current(engine) == get_alembic_head() + async with engine.connect() as conn: + tables = await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names())) + assert { + 'users', + 'bots', + 'agents', + 'runner_state', + 'directory_projection_inbox', + 'passkey_credentials', + } <= tables + assert await conn.scalar(sa.text('SELECT password FROM users')) == 'fresh-password' + await run_alembic_upgrade(engine, 'head') assert await get_alembic_current(engine) == get_alembic_head() @@ -480,18 +556,6 @@ async def test_certification_artifact_digest_backfill_is_safe_and_keeps_legacy_d ) -@pytest.mark.asyncio -async def test_certification_artifact_digest_backfill_accepts_fresh_current_schema(convergence_engine): - engine = convergence_engine - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_upgrade(engine, 'head') - await run_alembic_upgrade(engine, 'head') - - assert await get_alembic_current(engine) == get_alembic_head() - - @pytest.mark.asyncio async def test_merge_only_downgrade_preserves_both_branch_schemas(convergence_engine): engine = convergence_engine diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 9edfd5abb..602899195 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -122,39 +122,6 @@ class TestSQLiteMigrationBaseline: class TestSQLiteMigrationUpgrade: """Tests for upgrade to head workflow.""" - @pytest.mark.asyncio - async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine): - """A database released at the production-only 0016 head must remain upgradable.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - - @pytest.mark.asyncio - async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine): - """A database at the 4.11 development head must absorb later master migrations.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0018_merge_workspace_heads') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - - @pytest.mark.asyncio - async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): - """A database that already ran the feature migration must remain upgradable.""" - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') - await run_alembic_upgrade(sqlite_engine, 'head') - - assert await get_alembic_current(sqlite_engine) == _get_script_head() - @pytest.mark.asyncio async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine): async with sqlite_engine.begin() as conn: @@ -171,62 +138,6 @@ class TestSQLiteMigrationUpgrade: assert 'enabled' not in columns - @pytest.mark.asyncio - async def test_upgrade_from_baseline_to_head(self, sqlite_engine): - """ - Upgrade from baseline to head applies all migrations. - - Workflow: - 1. Create tables - 2. Stamp baseline - 3. Upgrade to head - 4. Verify current revision is head - """ - # Create tables - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp baseline - await run_alembic_stamp(sqlite_engine, '0001_baseline') - - # Upgrade to head - await run_alembic_upgrade(sqlite_engine, 'head') - - # 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. 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): - """ - Running upgrade to head multiple times is idempotent. - - Workflow: - 1. Upgrade to head - 2. Get revision - 3. Upgrade to head again - 4. Verify same revision - """ - # Create tables - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp and upgrade - await run_alembic_stamp(sqlite_engine, '0001_baseline') - await run_alembic_upgrade(sqlite_engine, 'head') - - rev1 = await get_alembic_current(sqlite_engine) - - # Upgrade again - should be idempotent - await run_alembic_upgrade(sqlite_engine, 'head') - - rev2 = await get_alembic_current(sqlite_engine) - assert rev2 == rev1, f'Expected {rev1}, got {rev2}' - @pytest.mark.asyncio async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine): """The PostgreSQL pgvector revision also evolves the OSS ORM schema.""" @@ -340,87 +251,14 @@ class TestSQLiteMigrationFreshDatabase: assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials' @pytest.mark.asyncio - async def test_fresh_db_upgrade_from_scratch(self, tmp_path): - """ - Fresh database (no tables) can be upgraded directly to head. - - Workflow: - 1. Create fresh engine with new DB file - 2. Create tables - 3. Upgrade to head - 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_engine = create_async_engine(fresh_url) - - # Create tables on fresh DB - async with fresh_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Upgrade to head directly (no baseline stamp) - await run_alembic_upgrade(fresh_engine, 'head') - - # Verify revision - rev = await get_alembic_current(fresh_engine) - assert rev is not None, 'Expected a revision on fresh DB' - - await fresh_engine.dispose() - - @pytest.mark.asyncio - async def test_fresh_db_without_create_all_behavior(self, tmp_path): - """ - Fresh database without create_all - test actual behavior. - - This tests what happens when migrations run on truly empty DB. - The behavior is determined by Alembic and migration scripts. - - EXPECTED: Either: - 1. Migration succeeds (if scripts handle empty DB) - 2. Migration fails with specific error (if scripts require tables) - - 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_engine = create_async_engine(fresh_url) - - # Capture the actual behavior - actual_result = None - actual_error = None - - try: - await run_alembic_upgrade(fresh_engine, 'head') - rev = await get_alembic_current(fresh_engine) - actual_result = rev - except Exception as e: - actual_error = e - - await fresh_engine.dispose() - - # 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' - 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' - # 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 - ] - 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}' - ) + async def test_fresh_db_without_create_all_behavior(self, sqlite_engine): + """Bare Alembic reaches head but relies on ORM bootstrap for core tables.""" + await run_alembic_upgrade(sqlite_engine, 'head') + assert await get_alembic_current(sqlite_engine) == _get_script_head() + async with sqlite_engine.connect() as conn: + tables = await conn.run_sync(lambda sync: set(sqlalchemy.inspect(sync).get_table_names())) + assert {'agents', 'runner_state', 'directory_projection_states', 'directory_projection_inbox'} <= tables + assert not {'users', 'bots', 'workspaces'} & tables class TestSQLiteMigrationGetCurrent: @@ -438,16 +276,3 @@ 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}' - - @pytest.mark.asyncio - async def test_get_current_after_stamp_returns_revision(self, sqlite_engine): - """ - get_alembic_current returns correct revision after stamp. - """ - async with sqlite_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(sqlite_engine, '0001_baseline') - - rev = await get_alembic_current(sqlite_engine) - assert rev == '0001_baseline' diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index babe2c207..cb1bd807a 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -424,62 +424,6 @@ class TestPostgreSQLMigrationBaseline: 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): - """ - Upgrade from baseline to head applies all migrations. - - Workflow: - 1. Create tables - 2. Stamp baseline - 3. Upgrade to head - 4. Verify current revision is head - """ - # Create tables - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp baseline - await run_alembic_stamp(postgres_engine, '0001_baseline') - - # Upgrade to head - await run_alembic_upgrade(postgres_engine, 'head') - - # 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. 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): - """ - Running upgrade to head multiple times is idempotent. - - Workflow: - 1. Upgrade to head - 2. Get revision - 3. Upgrade to head again - 4. Verify same revision - """ - # Create tables - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - # Stamp and upgrade - await run_alembic_stamp(postgres_engine, '0001_baseline') - await run_alembic_upgrade(postgres_engine, 'head') - - rev1 = await get_alembic_current(postgres_engine) - - # Upgrade again - should be idempotent - await run_alembic_upgrade(postgres_engine, 'head') - - rev2 = await get_alembic_current(postgres_engine) - assert rev2 == rev1, f'Expected {rev1}, got {rev2}' - @pytest.mark.asyncio @pytest.mark.parametrize('settings_type', ['TEXT', 'JSON']) async def test_legacy_knowledge_restore_statement_accepts_historical_and_fresh_settings_types( @@ -550,21 +494,6 @@ class TestPostgreSQLMigrationGetCurrent: rev = await get_alembic_current(postgres_engine) 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( - self, postgres_engine, clean_tables, clean_alembic_version - ): - """ - get_alembic_current returns correct revision after stamp. - """ - async with postgres_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await run_alembic_stamp(postgres_engine, '0001_baseline') - - rev = await get_alembic_current(postgres_engine) - assert rev == '0001_baseline' - class TestPostgreSQLWorkspaceMigration: """Focused coverage for upgrading a pre-tenancy PostgreSQL instance.""" @@ -929,7 +858,7 @@ class TestPostgreSQLTenantRuntime: description='', adapter='capacity-probe', adapter_config={}, - enable=False, + enable=True, event_bindings=[], ), persistence_pipeline.LegacyPipeline( @@ -1070,7 +999,27 @@ class TestPostgreSQLTenantRuntime: await rag_manager.load_knowledge_bases_from_db() mcp_loader = MCPLoader(runtime_application) - mcp_loader.host_mcp_server = AsyncMock() + active_hosts = 0 + peak_active_hosts = 0 + peak_host_tasks = 0 + track_hosted_task = mcp_loader.track_hosted_task + + def observe_host_task(task, context): + nonlocal peak_host_tasks + track_hosted_task(task, context) + peak_host_tasks = max(peak_host_tasks, len(mcp_loader._hosted_mcp_tasks)) + + async def host_probe(_context, _config): + nonlocal active_hosts, peak_active_hosts + active_hosts += 1 + peak_active_hosts = max(peak_active_hosts, active_hosts) + try: + await asyncio.sleep(0) + finally: + active_hosts -= 1 + + mcp_loader.track_hosted_task = observe_host_task + mcp_loader.host_mcp_server = AsyncMock(side_effect=host_probe) await mcp_loader.load_mcp_servers_from_db() dispatch_tasks = tuple(mcp_loader._host_dispatch_tasks) if dispatch_tasks: @@ -1111,6 +1060,12 @@ class TestPostgreSQLTenantRuntime: assert len(pipeline_manager.pipelines) == workspace_count assert len(rag_manager.knowledge_bases) == workspace_count assert mcp_loader.host_mcp_server.await_count == workspace_count + assert 0 < peak_active_hosts <= min(workspace_count, 8) + assert 0 < peak_host_tasks <= min(workspace_count, 8) + assert active_hosts == 0 + logging.getLogger('postgres-capacity-runtime-test').info( + 'MCP startup peaks: active_hosts=%d hosted_tasks=%d', peak_active_hosts, peak_host_tasks + ) assert not mcp_loader._host_dispatch_tasks assert not mcp_loader._hosted_mcp_tasks assert len(plugin_handler.reconciled) == workspace_count diff --git a/tests/integration/persistence/test_monitoring_postgres.py b/tests/integration/persistence/test_monitoring_postgres.py index ebfee7a7e..d46ff2107 100644 --- a/tests/integration/persistence/test_monitoring_postgres.py +++ b/tests/integration/persistence/test_monitoring_postgres.py @@ -7,6 +7,7 @@ no existing tables are dropped. Without that URL these tests are skipped. from __future__ import annotations +import datetime import logging import os import uuid @@ -19,6 +20,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.entity.persistence.agent_run import AgentRun from langbot.pkg.entity.persistence import monitoring as models from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode @@ -158,6 +160,56 @@ async def _read(service, method, context, *args, **kwargs): return await getattr(service, method)(context, *args, **kwargs) +async def test_inflight_snapshot_reads_recent_utc_rows_and_terminal_transitions(service, cloud_database): + _, admin = cloud_database + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + old = now - datetime.timedelta(hours=1) + async with admin.begin() as conn: + for workspace, run_id, status, created_at in [ + (WORKSPACE_A, 'inflight-running', 'running', old), + (WORKSPACE_A, 'inflight-recent', 'completed', now), + (WORKSPACE_B, 'inflight-private', 'running', now), + ]: + await conn.execute( + sa.insert(AgentRun).values( + run_id=run_id, workspace_id=workspace, runner_id='runner', status=status, created_at=created_at + ) + ) + for message_id, timestamp in [('inflight-message', now), ('inflight-old-message', old)]: + await conn.execute( + sa.insert(models.MonitoringMessage).values( + id=message_id, + workspace_uuid=WORKSPACE_A, + timestamp=timestamp, + **RESOURCE, + message_content='hello', + session_id='session', + status='success', + level='info', + ) + ) + + try: + # The SSE producer owns its tenant scope after the HTTP handler has returned. + snapshot = await service.get_inflight_snapshot(_context(WORKSPACE_A)) + assert snapshot['active_total'] == 1 + assert {row['id'] for row in snapshot['items']} == {'inflight-running', 'inflight-recent', 'inflight-message'} + async with admin.begin() as conn: + await conn.execute( + sa.update(AgentRun).where(AgentRun.run_id == 'inflight-running').values(status='completed') + ) + snapshot = await service.get_inflight_snapshot(_context(WORKSPACE_A), snapshot['items']) + assert snapshot['active_total'] == 0 + assert next(row for row in snapshot['items'] if row['id'] == 'inflight-running')['status_group'] == 'completed' + finally: + async with admin.begin() as conn: + await conn.execute( + sa.delete(AgentRun).where( + AgentRun.run_id.in_(['inflight-running', 'inflight-recent', 'inflight-private']) + ) + ) + + def _query(context, sender_id): return SimpleNamespace( _execution_context=context, diff --git a/tests/integration/persistence/test_pipeline_admission_postgres.py b/tests/integration/persistence/test_pipeline_admission_postgres.py index 6c95eae63..865a158eb 100644 --- a/tests/integration/persistence/test_pipeline_admission_postgres.py +++ b/tests/integration/persistence/test_pipeline_admission_postgres.py @@ -12,7 +12,11 @@ from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL') -pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')] +pytestmark = [ + pytest.mark.postgres, + pytest.mark.asyncio, + pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required'), +] @pytest.fixture @@ -111,6 +115,7 @@ async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome) assert (await writer)[0]['status'] == 'pending' +@pytest.mark.contract('admission_isolation') async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env): await write(env) async with env.pm.tenant_uow(base.WS) as uow: diff --git a/tests/integration/persistence/test_rag_document_identity.py b/tests/integration/persistence/test_rag_document_identity.py index 5a729dd87..21cc6d7dc 100644 --- a/tests/integration/persistence/test_rag_document_identity.py +++ b/tests/integration/persistence/test_rag_document_identity.py @@ -54,7 +54,7 @@ DOCUMENT_IDENTITY_REVISION = '0025_rag_document_identity' CONTEXT = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=5) -@pytest_asyncio.fixture(params=['sqlite', 'postgres']) +@pytest_asyncio.fixture(params=['sqlite', pytest.param('postgres', marks=pytest.mark.postgres)]) async def database(request, tmp_path): admin = None schema = 'ke_identity_' + uuid.uuid4().hex @@ -183,6 +183,7 @@ async def read_file_row(runtime, file_id='host-id'): @pytest.mark.parametrize( 'engine_id', ['dify-upstream', 'fastgpt-collection', 'ragflow-upstream', 'host-id', ' opaque ID '] ) +@pytest.mark.contract('rag_identity') async def test_ingest_persists_exact_engine_identity_and_restart_delete(runtime, engine_id): file = await seed(runtime) runtime.ap.plugin_connector.call_rag_ingest.return_value['document_id'] = engine_id diff --git a/tests/integration/persistence/test_rag_interrupted_ingestion.py b/tests/integration/persistence/test_rag_interrupted_ingestion.py index aa6b53dc6..8e6d77c26 100644 --- a/tests/integration/persistence/test_rag_interrupted_ingestion.py +++ b/tests/integration/persistence/test_rag_interrupted_ingestion.py @@ -25,6 +25,7 @@ def attach_task_manager(runtime): @pytest.mark.asyncio +@pytest.mark.contract('rag_recovery') async def test_cancelled_ingestion_retains_recovery_resources(runtime): """Local cancellation is interrupted observation, not remote quiescence.""" file = await seed(runtime) diff --git a/tests/integration/persistence/test_release_41011_upgrade.py b/tests/integration/persistence/test_release_41011_upgrade.py new file mode 100644 index 000000000..ad73be4e6 --- /dev/null +++ b/tests/integration/persistence/test_release_41011_upgrade.py @@ -0,0 +1,130 @@ +"""Upgrade the immutable v4.10.11 ORM schema, not current metadata.""" + +import json + +import pytest +import sqlalchemy as sa +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade +from tests.integration.persistence.test_migration_branch_convergence import ( + FIXTURES, + _seed_common, +) + + +@pytest.fixture(params=['sqlite', 'postgresql']) +async def convergence_engine(request, tmp_path): + import os + import uuid + from sqlalchemy.ext.asyncio import create_async_engine + + if request.param == 'sqlite': + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "release.db"}') + try: + yield engine + finally: + await engine.dispose() + return + url = os.environ.get('TEST_POSTGRES_URL') + if not url: + pytest.skip('TEST_POSTGRES_URL required for disposable PostgreSQL') + name = 'release_audit_' + uuid.uuid4().hex + admin = create_async_engine(url, isolation_level='AUTOCOMMIT') + async with admin.connect() as conn: + await conn.exec_driver_sql(f'CREATE DATABASE {name}') + engine = create_async_engine(sa.engine.make_url(url).set(database=name)) + try: + yield engine + finally: + await engine.dispose() + async with admin.connect() as conn: + await conn.exec_driver_sql(f'DROP DATABASE {name}') + await admin.dispose() + + +@pytest.mark.asyncio +async def test_release_41011_upgrade(convergence_engine, monkeypatch): + from langbot.pkg.utils import constants + + monkeypatch.setattr(constants, 'instance_id', 'migration-convergence') + engine = convergence_engine + fixture = json.loads((FIXTURES / 'release_4_10_11_schema.json').read_text()) + async with engine.begin() as conn: + for statement in fixture['dialects'][engine.dialect.name]: + await conn.exec_driver_sql(statement) + # Apply the historical migrations too: pgvector is migration-owned, + # not represented in ORM metadata. + await run_alembic_upgrade(engine, fixture['revision']) + await _seed_common(engine) + async with engine.begin() as conn: + await conn.execute(sa.text("DELETE FROM metadata WHERE key='oss_workspace_uuid'")) + await conn.execute( + sa.text( + "INSERT INTO metadata (key,value) VALUES ('oss_workspace_uuid', '41100000-0000-4000-8000-000000000001')" + ) + ) + from types import SimpleNamespace + from unittest.mock import MagicMock + from langbot.pkg.persistence.mgr import PersistenceManager + + manager = PersistenceManager(SimpleNamespace(logger=MagicMock(), instance_uuid='migration-convergence')) + manager.db = SimpleNamespace(engine=engine, get_engine=lambda: engine) + await manager._initialize_managed_schema() + async with engine.connect() as conn: + assert await conn.scalar(sa.text('SELECT name FROM bots')) == 'Fixture bot' + assert await conn.scalar(sa.text('SELECT name FROM legacy_pipelines')) == 'Fixture pipeline' + bot = await conn.run_sync(lambda sync: sa.Table('bots', sa.MetaData(), autoload_with=sync)) + bindings = await conn.scalar(sa.select(bot.c.event_bindings)) + assert any(binding['target_uuid'] == 'pipeline-1' for binding in bindings) + assert await conn.scalar(sa.select(bot.c.plugin_processors)) == [] + kb = await conn.run_sync(lambda sync: sa.Table('knowledge_bases', sa.MetaData(), autoload_with=sync)) + assert all(await conn.scalars(sa.select(kb.c.initialized))) + if engine.dialect.name == 'sqlite': + assert (await conn.exec_driver_sql('PRAGMA foreign_key_check')).all() == [] + async with engine.connect() as conn: + + def differences(sync): + context = MigrationContext.configure(sync, opts={'compare_type': True}) + return compare_metadata(context, Base.metadata) + + diffs = await conn.run_sync(differences) + + def substantive(sync, diff): + if isinstance(diff, list): + return True + if diff[0] not in ('add_table', 'add_column', 'add_index', 'add_constraint'): + return False + if diff[0] in ('add_index', 'add_constraint'): + obj = diff[1] + inspector = sa.inspect(sync) + columns = [c.name for c in obj.columns] + existing = inspector.get_indexes(obj.table.name) + inspector.get_unique_constraints(obj.table.name) + if any( + item['column_names'] == columns and (diff[0] != 'add_constraint' or item.get('unique', True)) + for item in existing + ): + return False + if ( + diff[0] == 'add_constraint' + and inspector.get_pk_constraint(obj.table.name)['constrained_columns'] == columns + ): + return False + return True + + actionable = await conn.run_sync(lambda sync: [d for d in diffs if substantive(sync, d)]) + assert not actionable, repr(actionable) + await manager._initialize_managed_schema() + async with engine.connect() as conn: + assert await conn.scalar(sa.text('SELECT name FROM bots')) == 'Fixture bot' + assert await conn.scalar(sa.text('SELECT name FROM legacy_pipelines')) == 'Fixture pipeline' + bot = await conn.run_sync(lambda sync: sa.Table('bots', sa.MetaData(), autoload_with=sync)) + bindings = await conn.scalar(sa.select(bot.c.event_bindings)) + assert any(binding['target_uuid'] == 'pipeline-1' for binding in bindings) + assert await conn.scalar(sa.select(bot.c.plugin_processors)) == [] + kb = await conn.run_sync(lambda sync: sa.Table('knowledge_bases', sa.MetaData(), autoload_with=sync)) + assert all(await conn.scalars(sa.select(kb.c.initialized))) + if engine.dialect.name == 'sqlite': + assert (await conn.exec_driver_sql('PRAGMA foreign_key_check')).all() == [] diff --git a/tests/integration/persistence/test_runner_timestamps_postgres.py b/tests/integration/persistence/test_runner_timestamps_postgres.py index 445d3b3e0..732b40490 100644 --- a/tests/integration/persistence/test_runner_timestamps_postgres.py +++ b/tests/integration/persistence/test_runner_timestamps_postgres.py @@ -25,7 +25,12 @@ from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.event_log import EventLog from langbot.pkg.entity.persistence.transcript import Transcript -pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] +pytestmark = [ + pytest.mark.postgres, + pytest.mark.integration, + pytest.mark.slow, + pytest.mark.asyncio, +] UTC = dt.timezone.utc NOW = dt.datetime(2026, 9, 22, 4, 5, 6, 123000, tzinfo=UTC) @@ -34,7 +39,11 @@ TABLES = [AgentRun.__table__, AgentRunEvent.__table__, AgentRuntime.__table__, E def _published_schema(connection): with Operations.context(MigrationContext.configure(connection)): - for revision in ('58846a8d7a81_add_event_log_and_transcript_tables', '8d3a1f2c4b6e_add_agent_run_ledger'): + for revision in ( + '58846a8d7a81_add_event_log_and_transcript_tables', + '8d3a1f2c4b6e_add_agent_run_ledger', + '0036_execution_host_leases', + ): importlib.import_module(f'langbot.pkg.persistence.alembic.versions.{revision}').upgrade() @@ -106,6 +115,7 @@ async def _legacy_run(engine, *, status='queued'): ) +@pytest.mark.contract('runner_timestamps') async def test_run_lifecycle_deadline_events_and_stats(pg_engine): store = run_ledger_store.RunLedgerStore(pg_engine) deadline = NOW + dt.timedelta(minutes=5) @@ -119,6 +129,7 @@ async def test_run_lifecycle_deadline_events_and_stats(pg_engine): bot_id=None, event_type='message.received', source='debug', + data={}, delivery=SimpleNamespace(model_dump=lambda **kwargs: {}), ), binding=SimpleNamespace(binding_id='binding', agent_id='agent', processor_id='agent', processor_type='agent'), diff --git a/tests/integration/persistence/test_sqlite_concurrency.py b/tests/integration/persistence/test_sqlite_concurrency.py new file mode 100644 index 000000000..fbf3355e5 --- /dev/null +++ b/tests/integration/persistence/test_sqlite_concurrency.py @@ -0,0 +1,34 @@ +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa + +from langbot.pkg.core import app # noqa: F401 - initialize manager registration before database imports +from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + + +async def test_writer_commits_while_independent_reader_is_open(tmp_path): + application = SimpleNamespace( + instance_config=SimpleNamespace(data={'database': {'sqlite': {'path': str(tmp_path / 'concurrency.db')}}}) + ) + manager = SQLiteDatabaseManager(application) + await manager.initialize() + engine = manager.get_engine() + try: + async with engine.begin() as connection: + await connection.execute(sa.text('CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT)')) + await connection.execute(sa.text("INSERT INTO probe VALUES (1, 'old')")) + async with engine.connect() as reader: + await reader.execute(sa.text('BEGIN')) + assert (await reader.execute(sa.text('SELECT value FROM probe'))).scalar_one() == 'old' + async with engine.connect() as writer: + await writer.execute(sa.text('PRAGMA busy_timeout=250')) + await writer.execute(sa.text("UPDATE probe SET value='new' WHERE id=1")) + await writer.commit() + await reader.rollback() + async with engine.connect() as connection: + assert (await connection.execute(sa.text('SELECT value FROM probe'))).scalar_one() == 'new' + finally: + await engine.dispose() diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 351b86cfd..3987ed524 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -166,10 +166,14 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega async with legacy_engine.connect() as conn: workspace = ( - await conn.execute( - sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"), + ( + await conn.execute( + sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"), + ) ) - ).mappings().one() + .mappings() + .one() + ) membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one() assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test') @@ -196,7 +200,7 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f' old_workspace_uuid = '11111111-1111-4111-8111-111111111111' - canonical_uuid = workspace_uuid_from_instance_id(instance_id) + canonical_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f' schema = sa.MetaData() sa.Table( 'metadata', @@ -218,7 +222,15 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): sa.Column('id', sa.Integer, primary_key=True), sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), ) + sa.Table( + 'workspace_metadata', + schema, + sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False), + sa.Column('key', sa.String(255)), + sa.Column('value', sa.String(255)), + ) async with engine.begin() as conn: + await conn.execute(sa.text('PRAGMA foreign_keys = ON')) await conn.run_sync(schema.create_all) await conn.execute( sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id} @@ -233,6 +245,14 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'), {'uuid': old_workspace_uuid}, ) + await conn.execute( + sa.text("INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :value)"), + {'value': old_workspace_uuid}, + ) + await conn.execute( + sa.text("INSERT INTO workspace_metadata VALUES (:uuid, 'migration_probe', 'present')"), + {'uuid': old_workspace_uuid}, + ) await run_alembic_stamp(engine, '0016_support_admin_sessions') await run_alembic_upgrade(engine, 'head') @@ -240,6 +260,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path): async with engine.connect() as conn: assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid + assert ( + await conn.execute(sa.text('SELECT workspace_uuid FROM workspace_metadata')) + ).scalar_one() == canonical_uuid + assert ( + await conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")) + ).scalar_one() == canonical_uuid + assert (await conn.execute(sa.text('PRAGMA foreign_key_check'))).all() == [] await engine.dispose() @@ -497,51 +524,6 @@ async def test_persistence_startup_preserves_legacy_workspace_membership_with_fo await manager.shutdown() -async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): - engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') - try: - await _create_legacy_schema(engine) - await run_alembic_stamp(engine, '0008_mcp_resource_prefs') - await run_alembic_upgrade(engine, '0016_support_admin_sessions') - - async with engine.begin() as conn: - old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) - instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'")) - assert old_uuid - assert instance_uuid - await conn.execute( - sa.text( - 'INSERT INTO workspace_metadata (workspace_uuid, key, value) ' - "VALUES (:workspace_uuid, 'migration_probe', 'present')" - ), - {'workspace_uuid': old_uuid}, - ) - await conn.execute( - sa.text( - "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " - 'ON CONFLICT(key) DO UPDATE SET value = excluded.value' - ), - {'workspace_uuid': old_uuid}, - ) - - await run_alembic_upgrade(engine, 'head') - expected_uuid = workspace_uuid_from_instance_id(instance_uuid) - async with engine.connect() as conn: - assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid - assert ( - await conn.scalar( - sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") - ) - == expected_uuid - ) - assert ( - await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")) - == expected_uuid - ) - finally: - await engine.dispose() - - async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch): engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}') try: diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py index 1a51dd9e4..8ce63c4e3 100644 --- a/tests/integration/pipeline/test_full_flow.py +++ b/tests/integration/pipeline/test_full_flow.py @@ -2,7 +2,8 @@ Pipeline full-flow integration tests. Tests real pipeline stages with fake runner/provider. -Validates message processing through PreProcessor, Processor, and SendResponseBackStage. +Validates RuntimePipeline dispatch through PreProcessor, Processor, ResponseWrapper, +and SendResponseBackStage, with external services replaced by deterministic doubles. Uses RuntimePipeline directly (not PipelineManager) to avoid DB dependency. @@ -13,6 +14,7 @@ from __future__ import annotations import pytest import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from tests.factories import FakeApp, text_query, mock_platform_adapter @@ -23,76 +25,20 @@ from tests.factories.platform import FakePlatform pytestmark = pytest.mark.integration -# ============== FIXTURE FOR SYS.MODULES ISOLATION ============== +# ============== NORMAL APPLICATION IMPORT BOOTSTRAP ============== @pytest.fixture(scope='module') -def mock_circular_import_chain(): +def load_pipeline_modules(): + """Initialize the real import graph before importing individual stages. + + This matches normal startup and the pipeline unit-test bootstrap. No + sys.modules/package attributes are replaced, so later tests keep real + module identities and the complete stage registry. """ - Break circular import chain for pipeline modules using isolated_sys_modules. + from importlib import import_module - Chain: pipeline → core.app → http_controller → groups/plugins - - We mock minimal modules to allow importing RuntimePipeline, StageInstContainer, - and stage classes without triggering full application initialization. - - After mocking, we import the stage modules so decorators register them. - """ - from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope - - # Mock core.entities with LifecycleControlScope enum - mock_core_entities = Mock() - mock_core_entities.LifecycleControlScope = MockLifecycleControlScope - - # Mock core.app - Application class is referenced but not instantiated - mock_core_app = Mock() - - # Mock utils.importutil to avoid unrelated import-time registrations. - mock_importutil = Mock() - mock_importutil.import_modules_in_pkg = lambda pkg: None - mock_importutil.import_modules_in_pkgs = lambda pkgs: None - - # Modules to clear (force re-import after mocking) - clear = [ - 'langbot.pkg.pipeline.stage', - 'langbot.pkg.pipeline.entities', - 'langbot.pkg.pipeline.pipelinemgr', - 'langbot.pkg.pipeline.preproc.preproc', - 'langbot.pkg.pipeline.process.process', - 'langbot.pkg.pipeline.process.handler', - 'langbot.pkg.pipeline.process.handlers.chat', - 'langbot.pkg.pipeline.process.handlers.command', - 'langbot.pkg.pipeline.respback.respback', - ] - - with isolated_sys_modules( - mocks={ - 'langbot.pkg.core.entities': mock_core_entities, - 'langbot.pkg.core.app': mock_core_app, - 'langbot.pkg.utils.importutil': mock_importutil, - 'langbot.pkg.pipeline.controller': Mock(), - 'langbot.pkg.pipeline.pipelinemgr': Mock(), - }, - clear=clear, - ): - # Import stage modules AFTER clearing so decorators register them - from importlib import import_module - - # Import stage base first - import_module('langbot.pkg.pipeline.stage') - - # Import entities - import_module('langbot.pkg.pipeline.entities') - - # Import specific stages to register them - import_module('langbot.pkg.pipeline.preproc.preproc') - import_module('langbot.pkg.pipeline.process.process') - import_module('langbot.pkg.pipeline.respback.respback') - - # Import pipelinemgr for RuntimePipeline - import_module('langbot.pkg.pipeline.pipelinemgr') - - yield + import_module('langbot.pkg.pipeline.pipelinemgr') # ============== FAKE RUNNER ============== @@ -172,35 +118,18 @@ def pipeline_app(): """ app = FakeApp() - # Session/conversation mocks for PreProcessor - mock_session = Mock() - mock_session.launcher_type = Mock() - mock_session.launcher_type.value = 'person' - mock_session.launcher_id = 12345 - mock_session.sender_id = 12345 - mock_session.use_prompt_name = 'default' - mock_session.using_conversation = None + # Real SDK entities keep plugin event validation meaningful. + from langbot_plugin.api.entities.builtin.provider.session import Conversation, Session, LauncherTypes + from langbot_plugin.api.entities.builtin.provider.prompt import Prompt - # Create a simple class to mimic Prompt behavior - class MockPrompt: - def __init__(self, name, messages): - self.name = name - self.messages = messages - - def copy(self): - return MockPrompt(self.name, list(self.messages)) - - # Create real lists for messages - prompt_messages_list = [] - messages_list = [] - - mock_prompt = MockPrompt('default', prompt_messages_list) - mock_conversation = Mock() - mock_conversation.prompt = mock_prompt - mock_conversation.messages = messages_list - mock_conversation.uuid = 'test-conversation-uuid' - mock_conversation.update_time = None - mock_conversation.create_time = None + mock_session = Session(launcher_type=LauncherTypes.PERSON, launcher_id=12345, sender_id=12345) + mock_conversation = Conversation( + prompt=Prompt(name='default', messages=[]), + messages=[], + pipeline_uuid='test-pipeline-uuid', + bot_uuid='test-bot-uuid', + uuid='test-conversation-uuid', + ) async def get_scoped_session(query): context = query._execution_context @@ -223,6 +152,18 @@ def pipeline_app(): # Tool manager mock app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + app.workspace_service = AsyncMock() + app.workspace_service.get_execution_binding.return_value = SimpleNamespace( + instance_uuid='test-instance', + workspace_uuid='test-workspace', + placement_generation=1, + ) + app.query_pool.remove_query = AsyncMock(return_value=True) + app.bot_service = AsyncMock() + app.bot_service.get_bot.return_value = {'name': 'Test bot'} + app.monitoring_service = AsyncMock() + app.monitoring_service.record_message.return_value = 'test-monitoring-message' # Telemetry mock (required by ChatMessageHandler) app.telemetry = Mock() @@ -251,7 +192,10 @@ def set_fake_runner(pipeline_app): orchestrator = Mock() orchestrator.try_claim_steering_from_query = AsyncMock(return_value=False) + orchestrator.observed_queries = [] + async def run_from_query(query): + orchestrator.observed_queries.append(query) async for result in runner.run(query): yield result @@ -283,6 +227,7 @@ def create_minimal_pipeline_config(): 'misc': { 'at-sender': False, 'quote-origin': False, + 'track-function-calls': False, 'exception-handling': 'show-hint', 'failure-hint': 'Request failed.', }, @@ -320,7 +265,7 @@ async def collect_processor_results(processor, query, stage_name): # ============== TESTS ============== -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.usefixtures('load_pipeline_modules') class TestPipelineStageChainReal: """Tests for real pipeline stage chain.""" @@ -348,7 +293,7 @@ class TestPipelineStageChainReal: assert 'SendResponseBackStage' in stage.preregistered_stages -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.usefixtures('load_pipeline_modules') class TestPreProcessorStage: """Tests for PreProcessor stage alone.""" @@ -408,9 +353,12 @@ class TestPreProcessorStage: # Check user_message content assert result.new_query.user_message is not None assert result.new_query.user_message.role == 'user' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [ + ('text', 'test message content') + ] -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.usefixtures('load_pipeline_modules') class TestProcessorStage: """Tests for MessageProcessor stage.""" @@ -425,6 +373,14 @@ class TestProcessorStage: # Create query query = text_query('hello') + from langbot_plugin.api.entities.builtin.provider.session import Session + + query.session = Session( + launcher_type=query.launcher_type, + launcher_id=query.launcher_id, + sender_id=query.sender_id, + using_conversation=await pipeline_app.sess_mgr.get_conversation(), + ) query.adapter = adapter query.pipeline_config = create_minimal_pipeline_config() query.resp_messages = [] @@ -445,9 +401,13 @@ class TestProcessorStage: # Collect results using helper results = await collect_processor_results(processor_stage, query, 'MessageProcessor') - assert len(results) >= 1 - # Check that resp_messages was populated - assert len(query.resp_messages) >= 1 + from langbot.pkg.pipeline import entities + + assert pipeline_app.agent_run_orchestrator.observed_queries == [query] + assert [result.result_type for result in results] == [entities.ResultType.CONTINUE] + assert [(message.role, message.content) for message in query.resp_messages] == [ + ('assistant', 'LANGBOT_FAKE_PONG') + ] @pytest.mark.asyncio async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter): @@ -517,7 +477,7 @@ class TestProcessorStage: assert query.resp_messages[0] == reply_chain -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.usefixtures('load_pipeline_modules') class TestRunnerExceptionFlow: """Tests for runner exception handling.""" @@ -638,7 +598,7 @@ class TestRunnerExceptionFlow: assert results[0].user_notice is None -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.mark.usefixtures('load_pipeline_modules') class TestSendResponseBackStage: """Tests for SendResponseBackStage.""" @@ -768,136 +728,220 @@ class TestSendResponseBackStage: pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called() -@pytest.mark.usefixtures('mock_circular_import_chain') +@pytest.fixture +async def runtime_pipeline_factory(pipeline_app, load_pipeline_modules): + """Use real dispatch and stages; fake only the surrounding services.""" + from langbot.pkg.pipeline import pipelinemgr + from langbot.pkg.pipeline.preproc.preproc import PreProcessor + from langbot.pkg.pipeline.process.process import Processor + from langbot.pkg.pipeline.wrapper.wrapper import ResponseWrapper + from langbot.pkg.pipeline.respback.respback import SendResponseBackStage + + async def create(query): + context = query._execution_context + query.instance_uuid = context.instance_uuid + query.workspace_uuid = context.workspace_uuid + query.placement_generation = context.placement_generation + query.resp_message_chain = [] + query.adapter.on_monitoring_message_created = AsyncMock() + stage_specs = [ + ('PreProcessor', PreProcessor), + ('MessageProcessor', Processor), + ('ResponseWrapper', ResponseWrapper), + ('SendResponseBackStage', SendResponseBackStage), + ] + containers = [] + for name, cls in stage_specs: + instance = cls(pipeline_app) + await instance.initialize(query.pipeline_config) + containers.append(pipelinemgr.StageInstContainer(name, instance)) + entity = SimpleNamespace( + uuid=query.pipeline_uuid, + workspace_uuid=context.workspace_uuid, + name='Contract pipeline', + config=query.pipeline_config, + extensions_preferences={'enable_all_plugins': False, 'plugins': [{'author': 'test', 'name': 'observer'}]}, + ) + return pipelinemgr.RuntimePipeline(pipeline_app, entity, containers, context) + + return create + + +@pytest.fixture +def passthrough_plugin(pipeline_app): + """Return real SDK contexts, like a plugin which does not intercept a call.""" + from langbot_plugin.api.entities.context import EventContext + + def context_for(event, _bound_plugins): + return EventContext.from_event(event) + + pipeline_app.plugin_connector.emit_event.side_effect = context_for + return context_for + + +@pytest.mark.usefixtures('load_pipeline_modules') class TestStageChainIntegration: - """Tests for full stage chain (PreProcessor -> Processor -> SendResponseBackStage).""" + """Runtime dispatch must preserve input, output, plugin boundaries, and cleanup.""" @pytest.mark.asyncio - async def test_full_chain_text_message_flow(self, pipeline_app, fake_platform_adapter, set_fake_runner): - """ - Full chain: text message -> PreProcessor -> Processor -> SendResponseBackStage. - - Validates: - - PreProcessor sets up session, user_message - - Processor calls runner and populates resp_messages - - SendResponseBackStage calls adapter.reply_message - """ - from langbot.pkg.pipeline import entities - from langbot.pkg.pipeline.preproc import preproc - from langbot.pkg.pipeline.process import process - from langbot.pkg.pipeline.respback import respback - + async def test_full_chain_text_message_flow( + self, pipeline_app, fake_platform_adapter, set_fake_runner, runtime_pipeline_factory, passthrough_plugin + ): adapter, platform = fake_platform_adapter - - # Set fake runner - fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG') - set_fake_runner(fake_runner) - - # Create query - config = create_minimal_pipeline_config() - query = text_query('ping') + set_fake_runner(FakeRunner.returns('LANGBOT_FAKE_PONG')) + query = text_query('ping', pipeline_config=create_minimal_pipeline_config()) + query.pipeline_config['output']['misc']['quote-origin'] = True query.adapter = adapter - query.pipeline_config = config - query.resp_messages = [] - query.resp_message_chain = [] + runtime = await runtime_pipeline_factory(query) - # Mock plugin_connector for PreProcessor and Processor events - mock_event_ctx_preproc = Mock() - mock_event_ctx_preproc.event = Mock() - mock_event_ctx_preproc.event.default_prompt = [] - mock_event_ctx_preproc.event.prompt = [] + await runtime.run(query) - mock_event_ctx_processor = Mock() - mock_event_ctx_processor.is_prevented_default = Mock(return_value=False) - mock_event_ctx_processor.event = Mock() - mock_event_ctx_processor.event.user_message_alter = None - - pipeline_app.plugin_connector.emit_event = AsyncMock() - pipeline_app.plugin_connector.emit_event.side_effect = [ - mock_event_ctx_preproc, # PreProcessor PromptPreProcessing - mock_event_ctx_processor, # Processor NormalMessageReceived + assert pipeline_app.agent_run_orchestrator.observed_queries == [query] + assert str(query.user_message.get_content_platform_message_chain()) == 'ping' + assert [message.content for message in query.resp_messages] == ['LANGBOT_FAKE_PONG'] + assert [str(chain) for chain in query.resp_message_chain] == ['LANGBOT_FAKE_PONG'] + outbound = platform.get_outbound_messages() + assert len(outbound) == 1, pipeline_app.logger.error.call_args_list + assert outbound[0]['source'] is query.message_event + assert str(outbound[0]['message']) == 'LANGBOT_FAKE_PONG' + assert outbound[0]['quote_origin'] is True + assert platform.get_outbound_chunks() == [] + assert not query.variables.get('_monitoring_has_error'), pipeline_app.logger.error.call_args_list + pipeline_app.logger.error.assert_not_called() + emitted = pipeline_app.plugin_connector.emit_event.await_args_list + assert [call.args[0].event_name for call in emitted] == [ + 'PersonMessageReceived', + 'PromptPreProcessing', + 'PersonNormalMessageReceived', + 'NormalMessageResponded', ] + assert [call.args[1] for call in emitted] == [['test/observer']] * 4 + pipeline_app.query_pool.remove_query.assert_awaited_once_with(query) - # Create stages - preproc_stage = preproc.PreProcessor(pipeline_app) - processor_stage = process.Processor(pipeline_app) - await processor_stage.initialize(config) - respback_stage = respback.SendResponseBackStage(pipeline_app) + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'blocked_event', ['PersonMessageReceived', 'PersonNormalMessageReceived', 'NormalMessageResponded'] + ) + async def test_chain_stops_on_interrupt( + self, + pipeline_app, + fake_platform_adapter, + set_fake_runner, + runtime_pipeline_factory, + passthrough_plugin, + blocked_event, + ): + adapter, platform = fake_platform_adapter + set_fake_runner(FakeRunner.returns('MUST_NOT_BE_DELIVERED')) + query = text_query('hello', pipeline_config=create_minimal_pipeline_config()) + query.adapter = adapter + runtime = await runtime_pipeline_factory(query) - # Run PreProcessor - result1 = await preproc_stage.process(query, 'PreProcessor') - assert result1.result_type.value == entities.ResultType.CONTINUE.value - query = result1.new_query + def intercept(event, bound_plugins): + context = passthrough_plugin(event, bound_plugins) + if event.event_name == blocked_event: + context.prevent_default() + return context - # Run Processor - results = await collect_processor_results(processor_stage, query, 'MessageProcessor') - assert len(results) >= 1 + pipeline_app.plugin_connector.emit_event.side_effect = intercept + await runtime.run(query) - # Build resp_message_chain from resp_messages + event_names = [call.args[0].event_name for call in pipeline_app.plugin_connector.emit_event.await_args_list] + assert event_names[-1] == blocked_event + expected_runner_queries = [query] if blocked_event == 'NormalMessageResponded' else [] + assert pipeline_app.agent_run_orchestrator.observed_queries == expected_runner_queries + assert query.resp_message_chain == [] + assert platform.get_outbound_messages() == [] + assert platform.get_outbound_chunks() == [] + assert not query.variables.get('_monitoring_has_error'), pipeline_app.logger.error.call_args_list + pipeline_app.logger.error.assert_not_called() + pipeline_app.query_pool.remove_query.assert_awaited_once_with(query) + + @pytest.mark.asyncio + async def test_wrapper_plugin_reply_reaches_adapter( + self, pipeline_app, fake_platform_adapter, set_fake_runner, runtime_pipeline_factory, passthrough_plugin + ): 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)) + adapter, platform = fake_platform_adapter + set_fake_runner(FakeRunner.returns('original runner answer')) + query = text_query('hello', pipeline_config=create_minimal_pipeline_config()) + query.adapter = adapter + runtime = await runtime_pipeline_factory(query) - # Run SendResponseBackStage - result3 = await respback_stage.process(query, 'SendResponseBackStage') - assert result3.result_type.value == entities.ResultType.CONTINUE.value + def alter_response(event, bound_plugins): + context = passthrough_plugin(event, bound_plugins) + if event.event_name == 'NormalMessageResponded': + context.event.reply_message_chain = text_chain('plugin replacement') + return context + + pipeline_app.plugin_connector.emit_event.side_effect = alter_response + await runtime.run(query) - # Verify adapter was called outbound = platform.get_outbound_messages() - assert len(outbound) >= 1 + assert len(outbound) == 1, pipeline_app.logger.error.call_args_list + assert str(outbound[0]['message']) == 'plugin replacement' + assert [str(chain) for chain in query.resp_message_chain] == ['plugin replacement'] + pipeline_app.query_pool.remove_query.assert_awaited_once_with(query) @pytest.mark.asyncio - async def test_chain_stops_on_interrupt(self, pipeline_app, fake_platform_adapter): - """ - Chain should stop when a stage returns INTERRUPT. - - PreProcessor returns CONTINUE, Processor returns INTERRUPT (prevent_default). - """ - from langbot.pkg.pipeline import entities - from langbot.pkg.pipeline.preproc import preproc - from langbot.pkg.pipeline.process import process - + async def test_runner_failure_delivers_hint_and_cleans_up( + self, pipeline_app, fake_platform_adapter, set_fake_runner, runtime_pipeline_factory, passthrough_plugin + ): adapter, platform = fake_platform_adapter - - # Create query - query = text_query('hello') + set_fake_runner(FakeRunner.raises(RuntimeError('provider unavailable'))) + query = text_query('hello', pipeline_config=create_minimal_pipeline_config()) query.adapter = adapter - query.pipeline_config = create_minimal_pipeline_config() + runtime = await runtime_pipeline_factory(query) - # Mock plugin_connector - PreProcessor continues, Processor interrupts - mock_event_ctx_preproc = Mock() - mock_event_ctx_preproc.event = Mock() - mock_event_ctx_preproc.event.default_prompt = [] - mock_event_ctx_preproc.event.prompt = [] + await runtime.run(query) - mock_event_ctx_processor = Mock() - mock_event_ctx_processor.is_prevented_default = Mock(return_value=True) - mock_event_ctx_processor.event = Mock() - mock_event_ctx_processor.event.reply_message_chain = None - - pipeline_app.plugin_connector.emit_event = AsyncMock() - pipeline_app.plugin_connector.emit_event.side_effect = [ - mock_event_ctx_preproc, # PreProcessor PromptPreProcessing - mock_event_ctx_processor, # Processor NormalMessageReceived + outbound = platform.get_outbound_messages() + assert len(outbound) == 1, pipeline_app.logger.error.call_args_list + assert str(outbound[0]['message']) == 'Request failed.' + assert query.variables['_monitoring_has_error'] is True + assert query.resp_messages == [] + assert query.resp_message_chain == [] + assert [call.args[0].event_name for call in pipeline_app.plugin_connector.emit_event.await_args_list] == [ + 'PersonMessageReceived', + 'PromptPreProcessing', + 'PersonNormalMessageReceived', ] + pipeline_app.query_pool.remove_query.assert_awaited_once_with(query) - # Create stages - preproc_stage = preproc.PreProcessor(pipeline_app) - processor_stage = process.Processor(pipeline_app) - await processor_stage.initialize(query.pipeline_config) + @pytest.mark.asyncio + async def test_streaming_chain_delivers_each_chunk_with_one_response_id( + self, pipeline_app, set_fake_runner, runtime_pipeline_factory, passthrough_plugin + ): + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk - # Run PreProcessor - result1 = await preproc_stage.process(query, 'PreProcessor') - assert result1.result_type.value == entities.ResultType.CONTINUE.value - query = result1.new_query + class StreamingRunner(FakeRunner): + async def run(self, query): + yield MessageChunk(role='assistant', content='first', all_content='first', is_final=False) + yield MessageChunk(role='assistant', content=' second', all_content='first second', is_final=True) - # Run Processor - should INTERRUPT - results = await collect_processor_results(processor_stage, query, 'MessageProcessor') + platform = FakePlatform(stream_output_supported=True) + adapter = mock_platform_adapter(platform) + adapter.create_message_card = AsyncMock(side_effect=platform.create_message_card) + set_fake_runner(StreamingRunner) + query = text_query('stream please', pipeline_config=create_minimal_pipeline_config()) + query.adapter = adapter + runtime = await runtime_pipeline_factory(query) - assert len(results) == 1 - assert results[0].result_type.value == entities.ResultType.INTERRUPT.value + await runtime.run(query) - # Chain stops here - no resp_messages - assert len(query.resp_messages) == 0 + chunks = platform.get_outbound_chunks() + assert [(str(chunk['message']), chunk['is_final']) for chunk in chunks] == [ + ('first', False), + ('first second', True), + ], pipeline_app.logger.error.call_args_list + assert all(chunk['source'] is query.message_event for chunk in chunks) + response_ids = [chunk['bot_message'].resp_message_id for chunk in chunks] + assert response_ids[0] and response_ids == [response_ids[0]] * 2 + adapter.create_message_card.assert_awaited_once_with(response_ids[0], query.message_event) + assert [str(chain) for chain in query.resp_message_chain] == ['first second'] + assert platform.get_outbound_messages() == [] + assert pipeline_app.agent_run_orchestrator.observed_queries == [query] + assert not query.variables.get('_monitoring_has_error'), pipeline_app.logger.error.call_args_list + pipeline_app.logger.error.assert_not_called() + pipeline_app.query_pool.remove_query.assert_awaited_once_with(query) diff --git a/tests/integration/vector/test_seekdb.py b/tests/integration/vector/test_seekdb.py index 668072aba..6cf919bde 100644 --- a/tests/integration/vector/test_seekdb.py +++ b/tests/integration/vector/test_seekdb.py @@ -14,16 +14,14 @@ import uuid import pytest -pytest.importorskip('pyseekdb') - -from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase - - -pytestmark = [pytest.mark.integration, pytest.mark.slow] +pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.seekdb] @pytest.fixture async def backend(tmp_path): + pytest.importorskip('pyseekdb') + from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase + app = SimpleNamespace( instance_config=SimpleNamespace( data={ @@ -51,6 +49,7 @@ async def backend(tmp_path): @pytest.mark.asyncio +@pytest.mark.contract('vector_round_trip') async def test_upsert_and_text_round_trip(backend) -> None: database, collection = backend original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文' @@ -80,6 +79,7 @@ async def test_upsert_and_text_round_trip(backend) -> None: @pytest.mark.asyncio +@pytest.mark.contract('hybrid_relevance') async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None: database, collection = backend documents = [ diff --git a/tests/integration/vector/test_valkey_search.py b/tests/integration/vector/test_valkey_search.py index 4368a2bb0..ee4191122 100644 --- a/tests/integration/vector/test_valkey_search.py +++ b/tests/integration/vector/test_valkey_search.py @@ -169,6 +169,7 @@ async def test_full_text_search(backend): async def test_hybrid_filter_then_knn(backend): db, collection = backend ids, embeddings, metadatas, documents = _sample_docs() + documents = ['cats nearby', 'cats elsewhere', 'cats farther'] await db.add_embeddings(collection, ids, embeddings, metadatas, documents) result = await _poll_until( @@ -180,10 +181,10 @@ async def test_hybrid_filter_then_knn(backend): query_text='cats', filter={'file_id': 'fileA'}, ), - lambda r: len(r['ids'][0]) >= 1, + lambda r: len(r['ids'][0]) == 2, ) - # Only fileA docs (d1, d3) should be candidates. - assert set(result['ids'][0]).issubset({'d1', 'd3'}) + assert result['ids'][0] == ['d1', 'd3'] + assert [item['file_id'] for item in result['metadatas'][0]] == ['fileA', 'fileA'] @pytest.mark.asyncio @@ -191,16 +192,17 @@ 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() + documents = ['cats nearby', 'cats elsewhere', 'cats farther'] 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) + await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) == 3) 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] + assert r_low['ids'][0] == r_high['ids'][0] == ['d1', 'd3', 'd2'] @pytest.mark.asyncio @@ -257,6 +259,9 @@ async def test_delete_by_filter_returns_count(backend): deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'}) assert deleted == 2 + items, total = await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda result: result[1] == 1) + assert total == 1 + assert [item['id'] for item in items] == ['d2'] @pytest.mark.asyncio @@ -273,6 +278,10 @@ async def test_list_by_filter_pagination(backend): page2, total = await db.list_by_filter(collection, limit=2, offset=2) assert total == 3 assert len(page2) == 1 + first_ids = {item['id'] for item in page1} + second_ids = {item['id'] for item in page2} + assert first_ids.isdisjoint(second_ids) + assert first_ids | second_ids == {'d1', 'd2', 'd3'} @pytest.mark.asyncio @@ -282,7 +291,11 @@ async def test_delete_collection(backend): 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) + client = await db._ensure_client() + keys = [f'kb:{collection}:{document_id}' for document_id in ids] + assert await client.exists(keys) == 3 await db.delete_collection(collection) + assert await client.exists(keys) == 0 # 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') diff --git a/tests/integration_tests/box/test_box_integration.py b/tests/integration_tests/box/test_box_integration.py index 9358515cd..02f5d4dd3 100644 --- a/tests/integration_tests/box/test_box_integration.py +++ b/tests/integration_tests/box/test_box_integration.py @@ -249,17 +249,68 @@ async def test_timeout_kills_command(box_client: ActionRPCBoxClient): @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, + """Only OFF blocks the same reachable private peer and installed HTTP client.""" + server = await box_client.execute( + BoxSpec( + cmd=r"""cat > /tmp/response.sh <<'HTTP' +#!/bin/sh +while IFS= read -r line; do + [ -z "$(printf '%s' "$line" | busybox tr -d '\r')" ] && break +done +printf 'HTTP/1.0 200 OK\r\nContent-Length: 19\r\n\r\nbox-network-control' +HTTP +chmod +x /tmp/response.sh +(while true; do busybox nc -l -p 8080 -e /bin/sh /tmp/response.sh; done >/tmp/http.log 2>&1 < /dev/null &) +sleep 1 && hostname -i""", + session_id='int-network-peer', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) ) - result = await box_client.execute(spec) + assert server.status == BoxExecutionStatus.COMPLETED + assert server.exit_code == 0, server.stderr + import ipaddress - assert result.exit_code != 0 + peer = str(ipaddress.IPv4Address(server.stdout.strip().split()[0])) + assert not ipaddress.ip_address(peer).is_loopback + command = f'wget -q -O - --timeout=3 http://{peer}:8080/' + online = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-online', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) + ) + assert online.status == BoxExecutionStatus.COMPLETED + assert online.exit_code == 0, online.stderr + assert online.stdout == 'box-network-control' + + offline = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-offline', + workdir='/tmp', + network=BoxNetworkMode.OFF, + image=_TEST_IMAGE, + ) + ) + assert offline.status == BoxExecutionStatus.COMPLETED + assert offline.exit_code != 0 + assert offline.stdout == '' + still_online = await box_client.execute( + BoxSpec( + cmd=command, + session_id='int-online', + workdir='/tmp', + network=BoxNetworkMode.ON, + image=_TEST_IMAGE, + ) + ) + assert still_online.exit_code == 0 + assert still_online.stdout == 'box-network-control' # ── 5. Backend unavailable ─────────────────────────────────────────── diff --git a/tests/integration_tests/box/test_cloud_box_admission_integration.py b/tests/integration_tests/box/test_cloud_box_admission_integration.py index 1080dc68d..c0bb8b961 100644 --- a/tests/integration_tests/box/test_cloud_box_admission_integration.py +++ b/tests/integration_tests/box/test_cloud_box_admission_integration.py @@ -30,6 +30,9 @@ from langbot.pkg.cloud.entitlements import ( EntitlementSnapshot, EntitlementUnavailableError, ) +from langbot.pkg.skill.manager import SkillManager +from langbot.pkg.skill.repository import SkillRepository +from langbot.pkg.provider.tools.loaders import skill as skill_loader pytestmark = pytest.mark.integration @@ -37,6 +40,7 @@ _UTC = dt.timezone.utc class _AdmissionBackend(BaseSandboxBackend): + execution_scoped_mounts = True name = 'nsjail' def __init__(self, logger): @@ -56,7 +60,7 @@ class _AdmissionBackend(BaseSandboxBackend): 'mount_isolation': True, 'network_isolation': True, 'hard_workspace_quota': True, - 'hard_skill_storage_quota': True, + 'hard_read_only_mount_quota': True, 'bounded_ephemeral_storage': True, 'inode_quota': True, } @@ -246,9 +250,18 @@ async def _stack(tmp_path): deployment=SimpleNamespace(multi_workspace_enabled=True), entitlement_resolver=EntitlementResolver('instance-a', entitlements), workspace_service=workspace_service, - instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}), + instance_config=SimpleNamespace( + data={ + 'skills': {'root': str(shared_root / 'skills')}, + 'box': box_config, + 'system': {'limitation': {}}, + } + ), ) + app.skill_repository = SkillRepository(app) + app.skill_mgr = SkillManager(app) service = BoxService(app, client=client) + app.box_service = service await service.initialize() return service, runtime, backend, entitlements, server_task, client_task @@ -329,7 +342,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path) @pytest.mark.asyncio -async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path): +async def test_cloud_core_skills_mount_generically_and_do_not_require_box_entitlement(tmp_path): service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path) first = _context('workspace-a') second = _context('workspace-b') @@ -341,54 +354,97 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm managed=False, ) try: - private = await service.create_skill( + repository = service.ap.skill_repository + await repository.create_skill( second, { 'name': 'private', 'instructions': 'workspace-b secret', }, ) - own_skill = await service.create_skill( + own_skill = await repository.create_skill( first, { 'name': 'runner', 'instructions': 'Run scripts/main.py', }, ) - await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')") - await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n') - refreshed_skill = await service.get_skill(first, 'runner') + script_revision = await repository.write_skill_file( + first, + 'runner', + 'scripts/main.py', + "print('ok')", + base_revision=own_skill['revision'], + ) + await repository.write_skill_file( + first, + 'runner', + 'requirements.txt', + 'requests==2.32.0\n', + base_revision=script_revision['revision'], + ) + refreshed_skill = await repository.get_skill(first, 'runner') assert refreshed_skill is not None assert refreshed_skill['python_project'] is True + await service.ap.skill_mgr.reload_skills(first) + query = await _bound_query(service, first, 91) + skill_loader.register_activated_skill(query, refreshed_skill) await service.execute_tool( { 'command': 'python /workspace/.skills/runner/scripts/main.py', 'workdir': '/workspace/.skills/runner', }, - await _bound_query(service, first, 91), - skill_name='runner', + query, + read_only_mounts=skill_loader.build_execution_mounts(service.ap, query), ) - mounted_spec = backend.started_specs[-1] + mounted_spec = backend.executed_specs[-1] assert len(mounted_spec.extra_mounts) == 1 - assert mounted_spec.extra_mounts[0].host_path == own_skill['package_root'] - assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner' + assert mounted_spec.extra_mounts[0].host_path == refreshed_skill['package_root'] + assert mounted_spec.extra_mounts[0].mount_path == '/opt/langbot-artifacts/' + refreshed_skill['revision'][7:] assert mounted_spec.extra_mounts[0].mode.value == 'ro' + assert mounted_spec.extra_mounts[0].content_digest == refreshed_skill['revision'] - with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'): - await service.scan_skill_directory(first, private['package_root']) - with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'): - await service.create_skill( - first, - { - 'name': 'stolen', - 'package_root': private['package_root'], - }, - ) - - assert await service.get_skill(first, 'private') is None + assert await repository.get_skill(first, 'private') is None + await repository.create_skill( + ineligible, + {'name': 'docs-only', 'instructions': 'Read this without Box.'}, + ) + assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only'] with pytest.raises(EntitlementUnavailableError): - await service.list_skills(ineligible) + await RunnerBoxService(service).acquire(ineligible, {'reuse_key': 'global'}, _query(ineligible, 92)) + finally: + server_task.cancel() + client_task.cancel() + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_activated_skill_mount_changes_preserve_shared_box_session(tmp_path): + service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path) + context = _context('workspace-a') + entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid) + try: + first_query = await _bound_query(service, context, 101) + await service.execute_tool({'command': 'echo before activation'}, first_query) + assert len(backend.started_specs) == 1 + + skill = await service.ap.skill_repository.create_skill( + context, {'name': 'demo', 'instructions': 'Run a script'} + ) + second_query = await _bound_query(service, context, 102) + skill_loader.register_activated_skill(second_query, skill) + await service.execute_tool( + {'command': 'echo with skill'}, + second_query, + read_only_mounts=skill_loader.build_execution_mounts(service.ap, second_query), + ) + assert len(backend.started_specs) == 1 + assert backend.stopped_sessions == [] + + await service.execute_tool({'command': 'echo first query again'}, first_query) + assert len(backend.started_specs) == 1 + assert backend.stopped_sessions == [] finally: server_task.cancel() client_task.cancel() diff --git a/tests/test_cwe94_debug_exec.py b/tests/test_cwe94_debug_exec.py index b31085981..ba91931a6 100644 --- a/tests/test_cwe94_debug_exec.py +++ b/tests/test_cwe94_debug_exec.py @@ -12,12 +12,17 @@ This test verifies that: import ast import pathlib +import pytest + +pytestmark = pytest.mark.security + # 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' +@pytest.mark.contract('debug_exec_absent') 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: @@ -38,6 +43,7 @@ def test_no_exec_call_in_system_controller(): ) +@pytest.mark.contract('debug_route_absent') def test_no_debug_exec_route(): """Verify the /debug/exec route is not registered.""" with open(VULN_FILE, 'r') as f: diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py index d6ba5f386..10ac2109e 100644 --- a/tests/unit_tests/agent/test_chat_handler.py +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -11,7 +11,6 @@ Avoids circular imports by using proper import structure. from __future__ import annotations -import uuid import pytest from unittest.mock import AsyncMock, MagicMock, patch @@ -20,7 +19,6 @@ from langbot.pkg.agent.runner.errors import ( RunnerExecutionError, RunnerNotAuthorizedError, ) -from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver # Define mock classes in dependency order (no forward references needed) @@ -174,95 +172,6 @@ class MockApplication: self.sess_mgr.get_conversation = AsyncMock() -class TestStreamingBehavior: - """Tests for streaming mode behavior.""" - - def test_single_resp_message_id_for_streaming(self): - """Streaming mode should use single resp_message_id for entire response.""" - # Simulate the streaming logic: resp_message_id created outside loop - resp_message_id = uuid.uuid4() - - chunks = ['Hello', ' World', '!'] - resp_messages = [] - - for chunk in chunks: - result = MockMessageChunk(chunk) - result.resp_message_id = str(resp_message_id) - - # Pop old chunk (streaming behavior) - if resp_messages: - resp_messages.pop() - resp_messages.append(result) - - # All chunks should have same resp_message_id - assert len(resp_messages) == 1 # Only last chunk remains after pop/append - assert resp_messages[0].resp_message_id == str(resp_message_id) - - def test_pop_before_append_in_streaming(self): - """Streaming mode should pop old chunk before appending new.""" - resp_message_id = uuid.uuid4() - resp_messages = [] - - # First chunk - no pop - chunk1 = MockMessageChunk('Hello') - chunk1.resp_message_id = str(resp_message_id) - resp_messages.append(chunk1) - assert len(resp_messages) == 1 - - # Second chunk - pop first, then append - if resp_messages: - resp_messages.pop() - chunk2 = MockMessageChunk('Hello World') - chunk2.resp_message_id = str(resp_message_id) - resp_messages.append(chunk2) - assert len(resp_messages) == 1 - assert resp_messages[0].content == 'Hello World' - - def test_non_streaming_no_pop(self): - """Non-streaming mode should NOT pop previous responses.""" - resp_messages = [] - - # First message - msg1 = MockMessageChunk('Response 1') - resp_messages.append(msg1) - assert len(resp_messages) == 1 - - # Second message - should NOT pop in non-streaming - msg2 = MockMessageChunk('Response 2') - resp_messages.append(msg2) - assert len(resp_messages) == 2 - - -class TestRunnerConfigResolverInChatHandler: - """Tests for RunnerConfigResolver usage in chat handler context.""" - - def test_resolve_runner_id_from_pipeline_config(self): - """Chat handler should use RunnerConfigResolver to resolve runner ID.""" - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:langbot-team/LocalAgent/default', - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - assert runner_id == 'plugin:langbot-team/LocalAgent/default' - - def test_old_runner_field_is_not_resolved(self): - """The 4.x path requires ai.runner.id.""" - pipeline_config = { - 'ai': { - 'runner': { - 'runner': 'local-agent', - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - assert runner_id is None - - class TestErrorHandling: """Tests for orchestrator error handling.""" @@ -671,3 +580,4 @@ class TestChatHandlerAsyncBehavior: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE assert len(query.resp_messages) == 1 + assert query.resp_messages[0].content == 'Reply from plugin' diff --git a/tests/unit_tests/agent/test_debug_prompt.py b/tests/unit_tests/agent/test_debug_prompt.py index 1ba35abf1..1c29c4915 100644 --- a/tests/unit_tests/agent/test_debug_prompt.py +++ b/tests/unit_tests/agent/test_debug_prompt.py @@ -27,6 +27,7 @@ from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelop from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store from langbot.pkg.agent.runner.session_registry import get_session_registry +from langbot.pkg.api.http.service.model import LLMModelsService from langbot.pkg.api.http.context import ( ExecutionContext, PrincipalContext, @@ -151,6 +152,7 @@ async def harness(): runner_registry=ScopedRunnerRegistry(), ) ap.plugin_connector = PromptPullConnector(ap) + ap.llm_model_service = LLMModelsService(ap) ap.agent_run_orchestrator = AgentRunOrchestrator(ap, ap.runner_registry) ap.agent_service = AgentService(ap) try: @@ -315,4 +317,4 @@ async def test_non_debug_query_keeps_effective_prompt_instead_of_static_config(h assert query.prompt is original assert pulled == [message.model_dump(mode='json') for message in original.messages] else: - assert pulled == [] + assert pulled == [Message(**message).model_dump(mode='json') for message in DEFAULT_PROMPT] diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index c052fabbb..2721ff630 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -60,6 +60,18 @@ class TestQueryToEventEnvelope: assert event.conversation_id == 'conv-uuid-123' + @pytest.mark.parametrize('launcher_id', [123456789, -100123456789, 'launcher-123']) + def test_query_to_event_accepts_platform_launcher_ids(self, mock_query, launcher_id): + """Telegram private and group IDs are numeric at the Query boundary.""" + mock_query.launcher_id = launcher_id + mock_query.session.using_conversation.uuid = None + + event = QueryEntryAdapter.query_to_event(mock_query) + context = QueryEntryAdapter._build_conversation_context(mock_query) + + assert context.launcher_id == str(launcher_id) + assert event.conversation_id == f'person_{launcher_id}' + def test_query_to_event_prefers_variable_conversation_id_when_conversation_uuid_missing(self, mock_query): """Pipeline variables can provide the conversation identity for state scope.""" mock_query.session.using_conversation.uuid = None @@ -291,6 +303,10 @@ class TestRunnerContextProtocolV1: assert ctx.event is not None assert ctx.event.event_type == 'message.received' + from pydantic import ValidationError + + with pytest.raises(ValidationError, match='event'): + RunnerContext.model_validate(ctx.model_dump(exclude={'event'})) def test_sdk_context_has_no_history_message_fields(self): """RunnerContext should not expose inline history message fields.""" @@ -320,16 +336,6 @@ class TestRunnerContextProtocolV1: assert not hasattr(ctx, 'bootstrap') -class TestHostManagedHistoryNotInProtocol: - """Test that Host-managed history payloads are not in Protocol v1.""" - - def test_messages_not_in_sdk_context_top_level(self): - """RunnerContext should not expose top-level history messages.""" - ctx_fields = RunnerContext.model_fields.keys() - - assert 'messages' not in ctx_fields - - class TestSDKResultProtocolV1: """Test SDK RunnerResult for Protocol v1.""" @@ -343,6 +349,8 @@ class TestSDKResultProtocolV1: ) assert result.run_id == 'run_1' + with pytest.raises(TypeError, match='run_id'): + RunnerResult.message_completed(message=Message(role='assistant', content='Hello')) # Fixtures diff --git a/tests/unit_tests/agent/test_event_log_transcript.py b/tests/unit_tests/agent/test_event_log_transcript.py index af709e33f..c31468751 100644 --- a/tests/unit_tests/agent/test_event_log_transcript.py +++ b/tests/unit_tests/agent/test_event_log_transcript.py @@ -16,7 +16,6 @@ from langbot.pkg.agent.runner.host_models import ( ) from langbot.pkg.agent.runner.event_log_store import EventLogStore from langbot.pkg.agent.runner.transcript_store import TranscriptStore -from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot_plugin.api.entities.builtin.runner.event import ( ActorContext, ) @@ -135,282 +134,36 @@ class TestEventLogStore: assert '"status": "queued"' in stored_event.metadata_json assert '"claimed_by_run_id": "run_1"' in stored_event.metadata_json - @pytest.mark.asyncio - async def test_append_event_truncates_input_summary(self, mock_db_engine): - """Test that long input summaries are truncated.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = EventLogStore(mock_db_engine) - - mock_session = AsyncMock() - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - long_text = 'x' * 2000 - event_id = await store.append_event( - event_id='evt_2', - event_type='message.received', - source='platform', - input_summary=long_text, - ) - - assert event_id == 'evt_2' - - @pytest.mark.asyncio - async def test_page_events_with_conversation_filter(self, mock_db_engine): - """Test paging events with conversation_id filter.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = EventLogStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [] - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - items, next_seq, has_more = await store.page_events( - conversation_id='conv_1', - limit=10, - ) - - assert isinstance(items, list) - class TestTranscriptStore: """Test TranscriptStore operations.""" @pytest.mark.asyncio - async def test_append_transcript(self, mock_db_engine): + async def test_append_transcript(self): """Test appending a transcript item.""" - from unittest.mock import AsyncMock, MagicMock, patch + from sqlalchemy.ext.asyncio import create_async_engine + from langbot.pkg.entity.persistence.base import Base + from uuid import UUID - store = TranscriptStore(mock_db_engine) - - mock_session = AsyncMock() - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - - # Mock _get_next_seq - with patch.object(store, '_get_next_seq', return_value=1): - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - transcript_id = await store.append_transcript( - transcript_id=None, # Auto-generate - event_id='evt_1', - conversation_id='conv_1', - role='user', - content='Hello', - ) - - assert transcript_id is not None - - @pytest.mark.asyncio - async def test_append_transcript_with_attachments(self, mock_db_engine): - """Test appending transcript with attachment refs.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_session = AsyncMock() - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - - with patch.object(store, '_get_next_seq', return_value=1): - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - transcript_id = await store.append_transcript( - transcript_id=None, # Auto-generate - event_id='evt_2', - conversation_id='conv_1', - role='assistant', - content="Here's an image", - attachment_refs=[{'id': 'att_1', 'type': 'image', 'url': 'http://example.com/img.png'}], - ) - - assert transcript_id is not None - - @pytest.mark.asyncio - async def test_page_transcript_backward(self, mock_db_engine): - """Test paging transcript backward (older items).""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [] - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - items, next_seq, prev_seq, has_more = await store.page_transcript( + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + store = TranscriptStore(engine) + transcript_id = await store.append_transcript( + transcript_id=None, + event_id='evt_1', conversation_id='conv_1', - limit=10, - direction='backward', + role='user', + content='Hello', ) - - assert isinstance(items, list) - - @pytest.mark.asyncio - async def test_page_transcript_has_hard_limit(self, mock_db_engine): - """Test that transcript paging has a hard limit.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [] - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - # Request more than the hard limit - items, next_seq, prev_seq, has_more = await store.page_transcript( - conversation_id='conv_1', - limit=200, # Request 200, but hard limit is 100 - ) - - # The store should cap at 100 - assert len(items) <= store.HARD_LIMIT - - @pytest.mark.asyncio - async def test_search_transcript(self, mock_db_engine): - """Test searching transcript.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalars.return_value.all.return_value = [] - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - items = await store.search_transcript( - conversation_id='conv_1', - query_text='database', - top_k=10, - ) - - assert isinstance(items, list) - - -class TestHistoryPageAuthorization: - """Test history.page authorization.""" - - @pytest.mark.asyncio - async def test_history_page_requires_run_id(self, mock_handler, mock_db_engine): - """Test history.page requires run_id.""" - from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction - - # Mock call_action to simulate the handler - result = await mock_handler.call_action( - PluginToRuntimeAction.HISTORY_PAGE, - {'run_id': None}, - ) - - # Should return error - assert result.get('ok') is False or 'error' in str(result).lower() - - @pytest.mark.asyncio - async def test_history_page_validates_conversation_scope(self, mock_db_engine): - """Test history.page only allows access to run's conversation.""" - # This test verifies the authorization logic - # The actual implementation validates conversation_id matches session - session_registry = get_session_registry() - - await session_registry.register( - run_id='run_1', - runner_id='plugin:test/plugin/runner', - query_id=None, - plugin_identity='test/plugin', - resources={'models': [], 'tools': [], 'knowledge_bases': [], 'storage': {'plugin_storage': True}}, - conversation_id='conv_1', - ) - - session = await session_registry.get('run_1') - assert session is not None - assert session['authorization']['conversation_id'] == 'conv_1' - - # Cleanup - await session_registry.unregister('run_1') - - -class TestEventGetAuthorization: - """Test event.get authorization.""" - - @pytest.mark.asyncio - async def test_event_get_requires_run_id(self, mock_handler): - """Test event.get requires run_id.""" - from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction - - result = await mock_handler.call_action( - PluginToRuntimeAction.EVENT_GET, - {'run_id': None, 'event_id': 'evt_1'}, - ) - - # Should return error - assert result.get('ok') is False or 'error' in str(result).lower() - - -class TestContextAccessPopulation: - """Test ContextAccess population in build_context_from_event.""" - - @pytest.mark.asyncio - async def test_context_access_has_history_apis_when_permitted(self, mock_db_engine): - """Test ContextAccess shows available APIs based on permissions.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = None - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - cursor = await store.get_latest_cursor('conv_1') - # Should return None or a cursor string - assert cursor is None or isinstance(cursor, str) - - @pytest.mark.asyncio - async def test_context_access_shows_has_history_before(self, mock_db_engine): - """Test ContextAccess indicates if history exists.""" - from unittest.mock import AsyncMock, MagicMock, patch - - store = TranscriptStore(mock_db_engine) - - mock_result = MagicMock() - mock_result.scalar.return_value = 0 - - mock_session = AsyncMock() - mock_session.execute = AsyncMock(return_value=mock_result) - - with patch.object(store, '_session_factory') as mock_factory: - mock_factory.return_value.__aenter__.return_value = mock_session - - has_history = await store.has_history_before('conv_1', 10) - assert isinstance(has_history, bool) + assert UUID(transcript_id).version == 4 + items, _, _, _ = await store.page_transcript('conv_1') + assert [(item['transcript_id'], item['event_id'], item['role'], item['content']) for item in items] == [ + (transcript_id, 'evt_1', 'user', 'Hello') + ] + finally: + await engine.dispose() class TestEventLogStoreRealSQLite: @@ -433,8 +186,9 @@ class TestEventLogStoreRealSQLite: await engine.dispose() @pytest.mark.asyncio - async def test_append_get_event_round_trip(self, db_engine): - """Test append_event -> get_event round trip with real DB.""" + @pytest.mark.parametrize(('summary', 'stored_summary'), [('x' * 1000, 'x' * 1000), ('x' * 1001, 'x' * 997 + '...')]) + async def test_append_get_event_round_trip(self, db_engine, summary, stored_summary): + """Event fields and the summary length boundary survive a real DB round trip.""" store = EventLogStore(db_engine) # Append event @@ -447,7 +201,7 @@ class TestEventLogStoreRealSQLite: actor_type='user', actor_id='user_001', actor_name='Test User', - input_summary='Hello world', + input_summary=summary, run_id='run_001', runner_id='plugin:test/plugin/runner', ) @@ -463,6 +217,7 @@ class TestEventLogStoreRealSQLite: assert event['conversation_id'] == 'conv_001' assert event['actor_type'] == 'user' assert event['actor_id'] == 'user_001' + assert event['input_summary'] == stored_summary @pytest.mark.asyncio async def test_page_events(self, db_engine): @@ -478,6 +233,12 @@ class TestEventLogStoreRealSQLite: conversation_id='conv_001', input_summary=f'Message {i}', ) + await store.append_event( + event_id='evt_other_conversation', + event_type='message.received', + source='platform', + conversation_id='conv_other', + ) # Page events items, next_seq, has_more = await store.page_events( @@ -485,13 +246,20 @@ class TestEventLogStoreRealSQLite: limit=3, ) - assert len(items) == 3 + assert [item['event_id'] for item in items] == ['evt_real_004', 'evt_real_003', 'evt_real_002'] assert has_more is True + assert next_seq == items[-1]['id'] + + older, next_seq, has_more = await store.page_events(conversation_id='conv_001', before_seq=next_seq, limit=3) + assert [item['event_id'] for item in older] == ['evt_real_001', 'evt_real_000'] + assert next_seq is None + assert has_more is False @pytest.mark.asyncio async def test_get_latest_cursor(self, db_engine): """Test get_latest_cursor with real DB.""" store = EventLogStore(db_engine) + assert await store.get_latest_cursor('conv_cursor') is None # Append events for i in range(3): @@ -504,8 +272,12 @@ class TestEventLogStoreRealSQLite: # Get latest cursor cursor = await store.get_latest_cursor('conv_cursor') - assert cursor is not None - assert int(cursor) > 0 + assert cursor == '3' + await store.append_event( + event_id='other', event_type='message.received', source='platform', conversation_id='other' + ) + assert await store.get_latest_cursor('conv_cursor') == '3' + assert await store.get_latest_cursor('other') == '4' @pytest.mark.asyncio async def test_cleanup_events_older_than(self, db_engine): @@ -568,8 +340,9 @@ class TestTranscriptStoreRealSQLite: @pytest.mark.asyncio async def test_append_page_transcript_round_trip(self, db_engine): - """Test append_transcript -> page_transcript round trip with real DB.""" + """Persist attachments and page backward without crossing conversations.""" store = TranscriptStore(db_engine) + attachments = [{'id': 'att_1', 'type': 'image', 'url': 'https://example.test/image.png'}] # Append transcript items for i in range(3): @@ -579,16 +352,51 @@ class TestTranscriptStoreRealSQLite: conversation_id='conv_001', role='user' if i % 2 == 0 else 'assistant', content=f'Message {i}', + attachment_refs=attachments if i == 2 else None, ) + await store.append_transcript( + transcript_id='trans_other', + event_id='evt_other', + conversation_id='conv_other', + role='user', + content='Other conversation', + ) # Page transcript items, next_seq, prev_seq, has_more = await store.page_transcript( conversation_id='conv_001', - limit=10, + limit=2, + include_attachments=True, ) - assert len(items) == 3 - assert items[0]['conversation_id'] == 'conv_001' + assert [item['transcript_id'] for item in items] == ['trans_real_002', 'trans_real_001'] + assert items[0]['attachment_refs'] == attachments + assert has_more is True + assert next_seq == items[-1]['seq'] + assert prev_seq == items[0]['seq'] + + older, next_seq, prev_seq, has_more = await store.page_transcript( + 'conv_001', before_seq=next_seq, limit=2, direction='backward' + ) + assert [item['transcript_id'] for item in older] == ['trans_real_000'] + assert next_seq is None + assert prev_seq == older[0]['seq'] + assert has_more is False + + @pytest.mark.asyncio + async def test_page_transcript_caps_results_at_100(self, db_engine): + store = TranscriptStore(db_engine) + for i in range(101): + await store.append_transcript( + transcript_id=f'cap_{i}', event_id=f'evt_{i}', conversation_id='conv_cap', role='user', content=str(i) + ) + + items, next_seq, prev_seq, has_more = await store.page_transcript('conv_cap', limit=200) + + assert [item['transcript_id'] for item in items] == [f'cap_{i}' for i in range(100, 0, -1)] + assert has_more is True + assert next_seq == items[-1]['seq'] + assert prev_seq == items[0]['seq'] @pytest.mark.asyncio async def test_get_legacy_provider_messages_projects_transcript_history(self, db_engine): @@ -694,6 +502,20 @@ class TestTranscriptStoreRealSQLite: role='assistant', content='Here is information about databases', ) + await store.append_transcript( + transcript_id='nonmatch', + event_id='evt_nonmatch', + conversation_id='conv_search', + role='user', + content='hello', + ) + await store.append_transcript( + transcript_id='foreign_match', + event_id='evt_foreign', + conversation_id='conv_other', + role='user', + content='foreign database', + ) # Search for "database" items = await store.search_transcript( @@ -701,13 +523,17 @@ class TestTranscriptStoreRealSQLite: query_text='database', ) - # Should find at least one match - assert len(items) >= 1 + assert [item['transcript_id'] for item in items] == ['trans_search_002', 'trans_search_001'] + limited = await store.search_transcript('conv_search', query_text='database', top_k=1) + assert [item['transcript_id'] for item in limited] == ['trans_search_002'] + assert await store.search_transcript('conv_search', query_text='missing') == [] @pytest.mark.asyncio async def test_get_latest_cursor_real_db(self, db_engine): - """Test get_latest_cursor with real DB.""" + """Cursors and history-before checks reflect persisted conversation rows.""" store = TranscriptStore(db_engine) + assert await store.get_latest_cursor('conv_cursor') is None + assert await store.has_history_before('conv_cursor', 1) is False # Append transcript items for i in range(3): @@ -721,8 +547,10 @@ class TestTranscriptStoreRealSQLite: # Get latest cursor cursor = await store.get_latest_cursor('conv_cursor') - assert cursor is not None - assert int(cursor) > 0 + assert cursor == '3' + assert await store.has_history_before('conv_cursor', 1) is False + assert await store.has_history_before('conv_cursor', 2) is True + assert await store.has_history_before('conv_other', 4) is False @pytest.mark.asyncio async def test_cleanup_transcripts_older_than(self, db_engine): @@ -775,21 +603,3 @@ def mock_db_engine(): engine = MagicMock(spec=AsyncEngine) return engine - - -@pytest.fixture -def mock_handler(): - """Create a mock handler for testing actions.""" - from langbot_plugin.runtime.io.handler import Handler - - class MockHandler(Handler): - def __init__(self): - self._responses = {} - - async def call_action(self, action, data, timeout=30): - # Simulate error response for missing run_id - if not data.get('run_id'): - return {'ok': False, 'message': 'run_id is required'} - return {'ok': True, 'data': {}} - - return MockHandler() diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index 60668414e..82be692b7 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -1,15 +1,4 @@ -"""Tests for RuntimeConnectionHandler proxy action authorization. - -Tests focus on: -- INVOKE_LLM authorization -- INVOKE_LLM_STREAM authorization -- CALL_TOOL authorization -- RETRIEVE_KNOWLEDGE_BASE authorization - -Authorization paths: -1. Runner calls: has run_id, validates against session_registry -2. Regular plugin calls: no run_id, unscoped plugin action path -""" +"""Tests for shared Runner resource authorization helpers and registry contracts.""" from __future__ import annotations @@ -178,126 +167,6 @@ class MockDisconnectCallback: return True -class TestInvokeLLMAuthorization: - """Tests for INVOKE_LLM authorization.""" - - @pytest.mark.asyncio - async def test_invoke_llm_authorized_with_run_id(self): - """INVOKE_LLM: authorized when model in session.resources.""" - # Setup registry with session - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_authorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Verify authorization logic directly - session = await registry.get('run_authorized') - assert session is not None - assert registry.is_resource_allowed(session, 'model', 'model_001') is True - - # Cleanup - await registry.unregister('run_authorized') - - @pytest.mark.asyncio - async def test_invoke_llm_unauthorized_with_run_id(self): - """INVOKE_LLM: unauthorized when model not in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_unauthorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Test authorization logic directly - session = await registry.get('run_unauthorized') - assert session is not None - # model_002 is not in resources - assert registry.is_resource_allowed(session, 'model', 'model_002') is False - - await registry.unregister('run_unauthorized') - - @pytest.mark.asyncio - async def test_invoke_llm_session_not_found(self): - """INVOKE_LLM: session not found should return error.""" - registry = AgentRunSessionRegistry() - - # No session registered for this run_id - session = await registry.get('run_nonexistent') - assert session is None - - @pytest.mark.asyncio - async def test_invoke_llm_no_run_id_unrestricted(self): - """INVOKE_LLM: no run_id should be unrestricted (backward compat).""" - # When no run_id is provided, the authorization check is skipped - # This is the unscoped path for regular plugin calls - - # Simulate: if not run_id, skip authorization - run_id = None - # Authorization check should NOT be triggered - assert run_id is None # No authorization check - - -class TestInvokeLLMStreamAuthorization: - """Tests for INVOKE_LLM_STREAM authorization.""" - - @pytest.mark.asyncio - async def test_invoke_llm_stream_authorized_with_run_id(self): - """INVOKE_LLM_STREAM: authorized when model in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_stream_authorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_stream_authorized') - assert session is not None - assert registry.is_resource_allowed(session, 'model', 'model_001') is True - - await registry.unregister('run_stream_authorized') - - @pytest.mark.asyncio - async def test_invoke_llm_stream_unauthorized_with_run_id(self): - """INVOKE_LLM_STREAM: unauthorized when model not in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_stream_unauthorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_stream_unauthorized') - assert session is not None - assert registry.is_resource_allowed(session, 'model', 'model_002') is False - - await registry.unregister('run_stream_unauthorized') - - @pytest.mark.asyncio - async def test_invoke_llm_stream_no_run_id_unrestricted(self): - """INVOKE_LLM_STREAM: no run_id should be unrestricted.""" - run_id = None - # No authorization check - assert run_id is None - - @pytest.mark.asyncio async def test_tool_manager_get_tool_detail_returns_uniform_schema(): """ToolManager.get_tool_detail returns a uniform host-level tool detail shape. @@ -333,557 +202,12 @@ async def test_tool_manager_get_tool_detail_returns_uniform_schema(): assert await mgr.get_tool_detail('workspace-test', 'missing') is None -class TestCallToolAuthorization: - """Tests for CALL_TOOL authorization.""" - - @pytest.mark.asyncio - async def test_call_tool_authorized_with_run_id(self): - """CALL_TOOL: authorized when tool in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(tools=[{'tool_name': 'web_search'}]) - - await registry.register( - run_id='run_tool_authorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_tool_authorized') - assert session is not None - assert registry.is_resource_allowed(session, 'tool', 'web_search') is True - - await registry.unregister('run_tool_authorized') - - @pytest.mark.asyncio - async def test_call_tool_unauthorized_with_run_id(self): - """CALL_TOOL: unauthorized when tool not in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(tools=[{'tool_name': 'web_search'}]) - - await registry.register( - run_id='run_tool_unauthorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_tool_unauthorized') - assert session is not None - assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False - - await registry.unregister('run_tool_unauthorized') - - @pytest.mark.asyncio - async def test_call_tool_no_run_id_unrestricted(self): - """CALL_TOOL: no run_id should be unrestricted.""" - run_id = None - # No authorization check - assert run_id is None - - -class TestRetrieveKnowledgeBaseAuthorization: - """Tests for RETRIEVE_KNOWLEDGE_BASE authorization.""" - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_authorized_with_run_id(self): - """RETRIEVE_KNOWLEDGE_BASE: authorized when kb in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) - - await registry.register( - run_id='run_kb_authorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_kb_authorized') - assert session is not None - assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True - - await registry.unregister('run_kb_authorized') - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_unauthorized_with_run_id(self): - """RETRIEVE_KNOWLEDGE_BASE: unauthorized when kb not in session.resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) - - await registry.register( - run_id='run_kb_unauthorized', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_kb_unauthorized') - assert session is not None - assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False - - await registry.unregister('run_kb_unauthorized') - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): - """RETRIEVE_KNOWLEDGE_BASE: no run_id checks pipeline config.""" - # When no run_id, the handler checks against pipeline's configured KBs - # This is the unscoped path for regular plugin calls - - from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver - - # Simulate pipeline config - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:test/runner/default', - }, - 'runner_config': { - 'plugin:test/runner/default': { - 'knowledge-bases': ['kb_001', 'kb_002'], - }, - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - assert runner_id == 'plugin:test/runner/default' - - runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) - allowed_kbs = runner_config.get('knowledge-bases', []) - assert 'kb_001' in allowed_kbs - assert 'kb_999' not in allowed_kbs - - -class TestAuthorizationPathDifferentiation: - """Tests that verify Runner vs regular plugin call differentiation.""" - - @pytest.mark.asyncio - async def test_runner_path_with_run_id(self): - """Runner calls provide run_id and use session_registry.""" - registry = AgentRunSessionRegistry() - - # Runner call has run_id - run_id = 'run_runner_123' - - # Register session with resources - await registry.register( - run_id=run_id, - runner_id='plugin:test/agent/default', - query_id=1, - plugin_identity='test/agent', - resources=make_resources( - models=[{'model_id': 'model_xyz'}], - tools=[{'tool_name': 'agent_tool'}], - knowledge_bases=[{'kb_id': 'kb_agent'}], - ), - ) - - session = await registry.get(run_id) - assert session is not None - - # Authorization checks - assert registry.is_resource_allowed(session, 'model', 'model_xyz') is True - assert registry.is_resource_allowed(session, 'model', 'other_model') is False - assert registry.is_resource_allowed(session, 'tool', 'agent_tool') is True - assert registry.is_resource_allowed(session, 'tool', 'other_tool') is False - assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_agent') is True - assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_other') is False - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_regular_plugin_path_no_run_id(self): - """Regular plugin calls have no run_id and skip session check.""" - # Regular plugin call has no run_id - run_id = None - - # Authorization check should be skipped when run_id is None. - # This is handled in handler.py with: if run_id: ... - assert run_id is None - - # For regular plugins: - # - INVOKE_LLM: unrestricted access to any model - # - CALL_TOOL: unrestricted access to any tool - # - RETRIEVE_KNOWLEDGE_BASE: checks pipeline config instead - - -class TestHandlerAuthorizationErrorMessages: - """Tests for error message content in authorization failures.""" - - def test_model_not_authorized_error_message(self): - """Error message should mention model not authorized.""" - expected_msg = 'Model model_999 is not authorized for this agent run' - assert 'not authorized' in expected_msg - assert 'model_999' in expected_msg - - def test_tool_not_authorized_error_message(self): - """Error message should mention tool not authorized.""" - expected_msg = 'Tool image_gen is not authorized for this agent run' - assert 'not authorized' in expected_msg - assert 'image_gen' in expected_msg - - def test_kb_not_authorized_error_message(self): - """Error message should mention kb not authorized.""" - expected_msg = 'Knowledge base kb_999 is not authorized for this agent run' - assert 'not authorized' in expected_msg - assert 'kb_999' in expected_msg - - def test_session_not_found_error_message(self): - """Error message should mention session not found.""" - expected_msg = 'Run session run_xyz not found or expired' - assert 'not found' in expected_msg - assert 'run_xyz' in expected_msg - - -class TestRETRIEVEKNOWLEDGEBASEBugFix: - """Tests for the RETRIEVE_KNOWLEDGE_BASE bug fix in handler.py. - - Bug: Previously, the handler directly accessed pipeline_config['ai']['local-agent'] - without first resolving the runner_id, causing issues when non-local-agent runners - were used. - - Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config. - """ - - def test_retrieve_kb_fix_local_runner(self): - """Fix should work for local-agent runner.""" - from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver - - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:langbot-team/LocalAgent/default', - }, - 'runner_config': { - 'plugin:langbot-team/LocalAgent/default': { - 'knowledge-bases': ['kb_001'], - }, - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) - allowed_kbs = runner_config.get('knowledge-bases', []) - - assert 'kb_001' in allowed_kbs - - def test_retrieve_kb_fix_other_runner(self): - """Fix should work for non-local-agent runners.""" - from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver - - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:custom/my-agent/default', - }, - 'runner_config': { - 'plugin:custom/my-agent/default': { - 'knowledge-bases': ['kb_custom'], - }, - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) - allowed_kbs = runner_config.get('knowledge-bases', []) - - assert 'kb_custom' in allowed_kbs - - def test_retrieve_kb_does_not_read_old_runner_format(self): - """The 4.x authorization path ignores legacy Pipeline runner fields.""" - from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver - - pipeline_config = { - 'ai': { - 'runner': { - 'runner': 'local-agent', - }, - 'local-agent': { - 'knowledge-bases': ['kb_legacy'], - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) - assert runner_id is None - assert runner_config == {} - - -class TestHandlerActionAuthorization: - """Tests for real handler action-level authorization. - - These tests simulate RuntimeConnectionHandler action handlers - to verify actual authorization behavior at the action level. - """ - - @pytest.mark.asyncio - async def test_invoke_llm_handler_authorized_path(self): - """INVOKE_LLM handler: authorized when model in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_invoke_llm_auth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Simulate handler authorization logic - run_id = 'run_invoke_llm_auth' - llm_model_uuid = 'model_001' - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check (same as handler.py line 352) - is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) - assert is_allowed is True - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_invoke_llm_handler_unauthorized_path(self): - """INVOKE_LLM handler: unauthorized when model not in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_invoke_llm_unauth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - run_id = 'run_invoke_llm_unauth' - llm_model_uuid = 'model_999' # Not in resources - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check (same as handler.py line 352) - is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid) - assert is_allowed is False - - # Should return error response (handler.py line 357) - expected_error = f'Model {llm_model_uuid} is not authorized for this agent run' - assert 'not authorized' in expected_error - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_invoke_llm_handler_session_not_found(self): - """INVOKE_LLM handler: session not found returns error.""" - registry = AgentRunSessionRegistry() - - # No session registered - run_id = 'run_nonexistent' - session = await registry.get(run_id) - assert session is None - - # Handler should return error (handler.py line 348) - expected_error = f'Run session {run_id} not found or expired' - assert 'not found' in expected_error - - @pytest.mark.asyncio - async def test_invoke_llm_handler_no_run_id_unrestricted(self): - """INVOKE_LLM handler: no run_id skips authorization (backward compat).""" - # Simulate handler logic: if not run_id, skip authorization - run_id = None - - # In handler.py, authorization check is inside: if run_id: ... - # So when run_id is None, authorization is skipped. - assert run_id is None - - @pytest.mark.asyncio - async def test_call_tool_handler_authorized_path(self): - """CALL_TOOL handler: authorized when tool in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(tools=[{'tool_name': 'web_search'}]) - - await registry.register( - run_id='run_call_tool_auth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - run_id = 'run_call_tool_auth' - tool_name = 'web_search' - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check (handler.py line 475) - is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) - assert is_allowed is True - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_call_tool_handler_unauthorized_path(self): - """CALL_TOOL handler: unauthorized when tool not in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(tools=[{'tool_name': 'web_search'}]) - - await registry.register( - run_id='run_call_tool_unauth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - run_id = 'run_call_tool_unauth' - tool_name = 'image_gen' # Not in resources - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check - is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name) - assert is_allowed is False - - # Should return error (handler.py line 480) - expected_error = f'Tool {tool_name} is not authorized for this agent run' - assert 'not authorized' in expected_error - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_call_tool_handler_no_run_id_unrestricted(self): - """CALL_TOOL handler: no run_id skips authorization.""" - run_id = None - - # Authorization check is inside: if run_id: ... - assert run_id is None - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_handler_authorized_path(self): - """RETRIEVE_KNOWLEDGE_BASE handler: authorized when kb in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) - - await registry.register( - run_id='run_kb_auth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - run_id = 'run_kb_auth' - kb_id = 'kb_001' - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check (handler.py line 889) - is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) - assert is_allowed is True - - await registry.unregister(run_id) - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_handler_unauthorized_path(self): - """RETRIEVE_KNOWLEDGE_BASE handler: unauthorized when kb not in resources.""" - registry = AgentRunSessionRegistry() - resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) - - await registry.register( - run_id='run_kb_unauth', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - run_id = 'run_kb_unauth' - kb_id = 'kb_999' # Not in resources - - session_registry = registry - session = await session_registry.get(run_id) - assert session is not None - - # Authorization check - is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id) - assert is_allowed is False - - # Should return error (handler.py line 894) - expected_error = f'Knowledge base {kb_id} is not authorized for this agent run' - assert 'not authorized' in expected_error - - await registry.unregister(run_id) - - class TestSDKRunnerAPIProxyFieldConsistency: """Tests for SDK RunnerAPIProxy field name consistency with Host handler. These tests verify that SDK sends field names that match what Host handler reads. """ - def test_call_tool_field_names_match(self): - """CALL_TOOL: SDK 'parameters' matches Host 'parameters'.""" - # SDK agent_run_api.py line 146: "parameters": parameters - # Host handler.py line 457: parameters = data['parameters'] - sdk_field = 'parameters' - host_field = 'parameters' - assert sdk_field == host_field - - def test_call_tool_run_id_field_present(self): - """CALL_TOOL: SDK includes 'run_id' field.""" - # SDK agent_run_api.py line 144: "run_id": self.run_id - # Host handler.py line 458: run_id = data.get('run_id') - sdk_fields = ['run_id', 'tool_name', 'parameters'] - host_expected_fields = ['tool_name', 'parameters', 'run_id'] - - for field in host_expected_fields: - assert field in sdk_fields - - def test_invoke_llm_field_names_match(self): - """INVOKE_LLM: SDK fields match Host handler.""" - # SDK agent_run_api.py lines 77-82 - sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args', 'timeout'] - # Host handler.py lines 333-337 - host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] - - for field in host_fields: - assert field in sdk_fields - - def test_invoke_llm_stream_field_names_match(self): - """INVOKE_LLM_STREAM: SDK fields match Host handler.""" - # SDK agent_run_api.py lines 111-116 - sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args'] - # Host handler.py lines 397-401 - host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id'] - - for field in host_fields: - assert field in sdk_fields - - def test_retrieve_knowledge_base_field_names_match(self): - """RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler.""" - # SDK agent_run_api.py lines 178-183 - sdk_fields = ['run_id', 'kb_id', 'query_text', 'top_k', 'filters'] - - # Note: query_id is from query context, not SDK proxy - for field in ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']: - assert field in sdk_fields - def test_retrieve_knowledge_base_action_enum_correct(self): """RETRIEVE_KNOWLEDGE_BASE: SDK uses correct action enum.""" from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction @@ -899,134 +223,9 @@ class TestSDKRunnerAPIProxyFieldConsistency: assert action != unrestricted_action -class TestNoRunIdBackwardCompatPath: - """Tests for unscoped plugin action path when no run_id is provided. - - Regular plugins (non-Runner) don't have run_id and should - have unrestricted access to certain APIs. - """ - - @pytest.mark.asyncio - async def test_invoke_llm_no_run_id_unrestricted_access(self): - """INVOKE_LLM: no run_id means unrestricted model access.""" - # Handler.py line 340: if run_id: ... - # When run_id is None, the authorization block is skipped - run_id = None - llm_model_uuid = 'any_model' - - # Simulate handler logic: no run_id skips authorization. - assert run_id is None - - # Model can be any UUID (unrestricted) - assert llm_model_uuid == 'any_model' - - @pytest.mark.asyncio - async def test_call_tool_no_run_id_unrestricted_access(self): - """CALL_TOOL: no run_id means unrestricted tool access.""" - run_id = None - tool_name = 'any_tool' - - # Handler.py line 463: if run_id: ... - assert run_id is None - - assert tool_name == 'any_tool' - - @pytest.mark.asyncio - async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): - """RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check.""" - from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver - - # When no run_id, handler.py lines 897-914 check pipeline config - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:test/runner/default', - }, - 'runner_config': { - 'plugin:test/runner/default': { - 'knowledge-bases': ['kb_001', 'kb_002'], - }, - }, - }, - } - - runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) - runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) - allowed_kb_uuids = runner_config.get('knowledge-bases', []) - - # kb_001 should be allowed - assert 'kb_001' in allowed_kb_uuids - # kb_999 should NOT be allowed - assert 'kb_999' not in allowed_kb_uuids - - class TestSessionExpiryAndCleanup: """Tests for session expiry and cleanup scenarios.""" - @pytest.mark.asyncio - async def test_session_expiry_detection(self): - """Session expiry: old session should be considered expired.""" - import time - - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - # Register session - await registry.register( - run_id='run_expiry_test', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_expiry_test') - assert session is not None - - # Check session status - started_at = session['status']['started_at'] - last_activity = session['status']['last_activity_at'] - assert last_activity >= started_at - - # Session should be valid initially - current_time = int(time.time()) - assert current_time - started_at < 10 # Less than 10 seconds old - - await registry.unregister('run_expiry_test') - - @pytest.mark.asyncio - async def test_cleanup_stale_sessions(self): - """Cleanup: stale sessions should be removed.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - # Register session - await registry.register( - run_id='run_cleanup_test', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Session exists - session = await registry.get('run_cleanup_test') - assert session is not None - - # Cleanup with max_age=0 (immediate cleanup) - # Note: This won't actually cleanup because session is just created - # We need to manually test cleanup logic - cleaned = await registry.cleanup_stale_sessions(max_age_seconds=0) - assert isinstance(cleaned, int) - - # Session should still exist (it was just created) - # With max_age=0, sessions with last_activity > 0 seconds ago would be cleaned - # But since it's just created, last_activity_at is current time - session_after = await registry.get('run_cleanup_test') - assert session_after is not None - - await registry.unregister('run_cleanup_test') - @pytest.mark.asyncio async def test_unregister_removes_session(self): """Unregister: session should be removed from registry.""" @@ -1182,70 +381,6 @@ class TestResourceTypeValidation: assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False -class TestBypassPrevention: - """Tests to ensure RunnerAPIProxy cannot bypass authorization.""" - - @pytest.mark.asyncio - async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self): - """Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action.""" - # RunnerAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id) - # RETRIEVE_KNOWLEDGE is unrestricted and separate - # Runner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization - - registry = AgentRunSessionRegistry() - resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) - - await registry.register( - run_id='run_bypass_test', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - session = await registry.get('run_bypass_test') - - # kb_002 is not authorized - assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False - - # If Runner tried to use RETRIEVE_KNOWLEDGE (unrestricted), - # it would bypass authorization - but RunnerAPIProxy correctly uses - # RETRIE_KNOWLEDGE_BASE which requires authorization - - from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction - - # Verify SDK uses correct action - assert PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE.value == 'retrieve_knowledge_base' - - await registry.unregister('run_bypass_test') - - @pytest.mark.asyncio - async def test_cannot_bypass_via_missing_run_id_in_session(self): - """Cannot bypass by using run_id that doesn't exist in registry.""" - registry = AgentRunSessionRegistry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_valid', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Try to use a run_id that doesn't exist - fake_run_id = 'run_fake' - session = await registry.get(fake_run_id) - assert session is None - - # Handler should return error for non-existent run_id - # (handler.py line 348, 466, 881) - expected_error = f'Run session {fake_run_id} not found or expired' - assert 'not found' in expected_error - - await registry.unregister('run_valid') - - class TestValidateRunAuthorizationHelper: """Tests for _validate_run_authorization helper function. @@ -1529,114 +664,6 @@ class TestStorageResourcePermissionHelper: assert registry.is_resource_allowed(session, 'storage', 'workspace') is False -class TestRealActionHandlerSimulation: - """Tests that simulate real RuntimeConnectionHandler action registration and execution. - - These tests attempt to verify the actual handler behavior without full integration. - Uses global session registry to match _validate_run_authorization behavior. - """ - - @pytest.mark.asyncio - async def test_action_handler_invoke_llm_flow(self): - """Simulate INVOKE_LLM action handler authorization flow.""" - # Use global session registry - from langbot.pkg.agent.runner.session_registry import get_session_registry - - registry = get_session_registry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_invoke_llm_flow_sim', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - # Simulate handler logic - from langbot.pkg.plugin.handler import _validate_run_authorization - - mock_ap = MagicMock() - mock_ap.logger = MagicMock() - - # Step 1: Validate authorization - session, error = await _validate_run_authorization( - 'run_invoke_llm_flow_sim', - 'model', - 'model_001', - mock_ap, - caller_plugin_identity='test/runner', - ) - - # Should pass authorization - assert session is not None - assert error is None - - # Step 2: Handler would invoke LLM (not tested here, would need mock model) - - await registry.unregister('run_invoke_llm_flow_sim') - - @pytest.mark.asyncio - async def test_action_handler_rejects_unauthorized_model(self): - """Simulate INVOKE_LLM handler rejecting unauthorized model.""" - # Use global session registry - from langbot.pkg.agent.runner.session_registry import get_session_registry - - registry = get_session_registry() - resources = make_resources(models=[{'model_id': 'model_001'}]) - - await registry.register( - run_id='run_reject_model_sim', - runner_id='plugin:test/runner/default', - query_id=1, - plugin_identity='test/runner', - resources=resources, - ) - - from langbot.pkg.plugin.handler import _validate_run_authorization - - mock_ap = MagicMock() - mock_ap.logger = MagicMock() - mock_ap.logger.warning = MagicMock() - - # Try to access unauthorized model - session, error = await _validate_run_authorization( - 'run_reject_model_sim', - 'model', - 'model_999', - mock_ap, - caller_plugin_identity='test/runner', - ) - - # Should reject - assert session is None - assert error is not None - assert 'not authorized' in error.message.lower() - assert mock_ap.logger.warning.called - - await registry.unregister('run_reject_model_sim') - - @pytest.mark.asyncio - async def test_action_handler_session_not_found_flow(self): - """Simulate handler behavior when session not found.""" - from langbot.pkg.plugin.handler import _validate_run_authorization - - mock_ap = MagicMock() - mock_ap.logger = MagicMock() - mock_ap.logger.warning = MagicMock() - - # Try to validate with non-existent run_id - session, error = await _validate_run_authorization( - 'run_nonexistent_session_flow', 'model', 'model_001', mock_ap - ) - - # Should return error - assert session is None - assert error is not None - assert 'not found' in error.message.lower() - assert mock_ap.logger.warning.called - - class TestStoragePermissionValidation: """Tests for Host-side storage permission validation via _validate_run_authorization. @@ -2002,32 +1029,3 @@ class TestCallerPluginIdentityValidation: assert 'no plugin_identity' in error.message await registry.unregister('run_missing_pull_identity') - - -class TestBackwardCompatStorageNoRunId: - """Tests for unscoped storage actions without run_id. - - Regular plugins (non-Runner) don't have run_id and should - have unrestricted access to storage APIs. - """ - - def test_storage_no_run_id_skips_validation(self): - """Storage actions without run_id skip Host-side validation.""" - # Handler.py: if run_id: ...validation... - # When run_id is None, validation is skipped - run_id = None - - # Simulate handler logic: no run_id skips validation. - assert run_id is None - - # Storage access unrestricted for regular plugins - assert run_id is None - - def test_file_no_run_id_skips_validation(self): - """GET_CONFIG_FILE without run_id skips Host-side validation.""" - run_id = None - - assert run_id is None - - # File access unrestricted for regular plugins - assert run_id is None diff --git a/tests/unit_tests/agent/test_history_event_api_auth.py b/tests/unit_tests/agent/test_history_event_api_auth.py index 732ce4eac..dc18ee2ea 100644 --- a/tests/unit_tests/agent/test_history_event_api_auth.py +++ b/tests/unit_tests/agent/test_history_event_api_auth.py @@ -213,6 +213,57 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi assert result.data['cursor'] == str(result.data['seq']) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('denial', 'message'), + [('missing_run', 'run_id is required'), ('expired_run', 'not found or expired'), ('capability', 'not authorized')], +) +async def test_event_get_requires_active_run_capability(session_registry, db_engine, denial, message): + await _register_session(session_registry, available_apis={'event_get': denial != 'capability'}) + await EventLogStore(db_engine).append_event( + event_id='evt_denied', + event_type='message.received', + source='platform', + conversation_id='conv_1', + run_id='run_1', + input_summary='must not be returned', + ) + data = {'event_id': 'evt_denied'} + if denial != 'missing_run': + data['run_id'] = 'expired' if denial == 'expired_run' else 'run_1' + result = await _handler(db_engine, session_registry).actions[PluginToRuntimeAction.EVENT_GET.value](data) + assert result.code != 0 + assert message in result.message + assert not result.data + + +@pytest.mark.asyncio +@pytest.mark.parametrize('mismatch', [None, 'conversation_id', 'bot_id', 'workspace_id', 'thread_id']) +async def test_event_get_enforces_run_scope(session_registry, db_engine, mismatch): + scope = {'conversation_id': 'conv_1', 'bot_id': 'bot_1', 'workspace_id': 'workspace-test', 'thread_id': 'thread_1'} + await _register_session(session_registry, **scope, available_apis={'event_get': True}) + event_scope = {**scope, **({mismatch: 'other'} if mismatch else {})} + await EventLogStore(db_engine).append_event( + event_id='evt_other_run', + event_type='message.received', + source='platform', + run_id='other_run', + input_summary='scoped document', + **event_scope, + ) + result = await _handler(db_engine, session_registry).actions[PluginToRuntimeAction.EVENT_GET.value]( + {'run_id': 'run_1', 'event_id': 'evt_other_run'} + ) + if mismatch is None: + assert result.code == 0 + assert result.data['event_id'] == 'evt_other_run' + assert result.data['input_summary'] == 'scoped document' + else: + assert result.code != 0 + assert 'not accessible' in result.message + assert not result.data + + @pytest.mark.asyncio async def test_event_page_returns_sdk_page_projection(session_registry, db_engine): await _register_session(session_registry, available_apis={'event_page': True}) diff --git a/tests/unit_tests/agent/test_legacy_identity_boundary.py b/tests/unit_tests/agent/test_legacy_identity_boundary.py index 241b818b4..d5347acfb 100644 --- a/tests/unit_tests/agent/test_legacy_identity_boundary.py +++ b/tests/unit_tests/agent/test_legacy_identity_boundary.py @@ -1,12 +1,15 @@ """Native identity provenance across the Host/SDK and persisted resume boundary.""" -import ast -from pathlib import Path +import hashlib +import json +from contextlib import nullcontext from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from langbot_plugin.api.entities.builtin.runner.context import RunnerContext +from langbot_plugin.api.proxies.invocation import bind_invocation +from langbot_plugin.entities.io.context import InstallationBinding from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder from langbot.pkg.agent.runner.host_models import AgentEventEnvelope @@ -16,6 +19,7 @@ from langbot.pkg.entity.persistence.base import Base from sqlalchemy.ext.asyncio import create_async_engine from tests.unit_tests.agent import test_event_first_protocol as event_fixtures from tests.unit_tests.agent.test_context_validation import TestContextValidation as Helpers +from tests.utils.legacy_plugin_contract import load_identity_helper, verified_plugin_root @pytest.fixture @@ -192,35 +196,59 @@ async def test_tbox_resume_without_persisted_identity_fails_closed(mock_query): assert context['runtime']['metadata']['bot_id'] is None +@pytest.fixture(scope='session') +def official_plugin_root(): + return verified_plugin_root() + + @pytest.mark.asyncio @pytest.mark.parametrize( - 'runner,folder,method', + 'runner,folder,method,error_name,local_identity', [ - ('DifyAgent', 'dify-agent', '_get_user_tag'), - ('N8nAgent', 'n8n-agent', '_get_user_tag'), - ('CozeAgent', 'coze-agent', '_get_user_id'), - ('TboxAgent', 'tbox-agent', '_get_user_id'), + pytest.param( + 'DifyAgent', + 'dify-agent', + '_get_user_tag', + 'DifyConfigError', + 'person_Session_007', + marks=pytest.mark.contract('dify_identity'), + ), + pytest.param( + 'N8nAgent', + 'n8n-agent', + '_get_user_tag', + 'N8nConfigError', + 'person_Session_007', + marks=pytest.mark.contract('n8n_identity'), + ), + pytest.param( + 'CozeAgent', + 'coze-agent', + '_get_user_id', + 'CozeConfigError', + 'person_launcher-123', + marks=pytest.mark.contract('coze_identity'), + ), + pytest.param( + 'TboxAgent', + 'tbox-agent', + '_get_user_id', + 'TboxConfigError', + 'bot-uuid-123', + marks=pytest.mark.contract('tbox_identity'), + ), ], ) -@pytest.mark.parametrize('missing', [False, True]) -async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner, folder, method, missing): - # Execute the actual pure helper AST without loading vendor clients or making network calls. - root = Path(__file__).resolve().parents[3].parent / 'langbot-plugins-411-migration' - path = root / 'Runner' / folder / 'components/runner/default.py' - if not path.exists(): - pytest.skip('sibling plugin source checkout is required for cross-repository contract gate') - tree = ast.parse(path.read_text()) - function = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == method) - - class ConfigError(Exception): - def __init__(self, message, **kwargs): - super().__init__(message) - - namespace = { - 'RunnerContext': RunnerContext, - **{name: ConfigError for name in ('DifyConfigError', 'N8nConfigError', 'CozeConfigError', 'TboxConfigError')}, - } - exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), 'exec'), namespace) +@pytest.mark.parametrize('missing', [False, True], ids=['trusted-identity', 'missing-identity']) +@pytest.mark.parametrize('profile', ['dedicated', 'shared']) +@pytest.mark.plugin_identity +async def test_real_plugin_identity_helper_with_host_payload( + mock_query, monkeypatch, official_plugin_root, runner, folder, method, error_name, local_identity, missing, profile +): + # Keep exact Host identity selection above; current supported plugins namespace + # that identity to prevent collisions across Workspaces and installations. + helper, config_error = load_identity_helper(official_plugin_root, folder, method, error_name) + monkeypatch.setenv('LANGBOT_PLUGIN_RUNTIME_PROFILE', profile) mock_query.session.launcher_id = 'Session_007' event = QueryEntryAdapter.query_to_event(mock_query) if missing: @@ -229,13 +257,34 @@ async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner, source = 'legacy-bot' if runner == 'TboxAgent' else 'legacy-session' context, _ = await build(event, runner, source) sdk = RunnerContext.model_validate(context) - if missing: - with pytest.raises(ConfigError, match='trusted Host identity'): - namespace[method](None, sdk) - else: - expected = ( - mock_query.bot_uuid - if runner == 'TboxAgent' - else ('person_launcher-123' if runner == 'CozeAgent' else 'person_Session_007') + handler = SimpleNamespace() + consumer = SimpleNamespace(_plugin_runtime_handler=handler) + binding = InstallationBinding( + instance_uuid='identity-instance', + workspace_uuid=event.workspace_id, + installation_uuid='00000000-0000-4000-8000-000000000007', + runtime_revision=1, + artifact_digest='a' * 64, + ) + if profile == 'shared': + # A stale connection binding must never win over the active invocation. + handler.bound_action_context = binding.model_copy( + update={'workspace_uuid': 'foreign-workspace', 'installation_uuid': 'foreign-installation'} ) - assert namespace[method](None, sdk) == expected + authority = bind_invocation(handler, binding=binding) if profile == 'shared' else nullcontext() + with authority: + if missing: + with pytest.raises(config_error, match='trusted Host identity') as caught: + helper(consumer, sdk) + assert caught.value.code == f'{folder.removesuffix("-agent")}.identity_unavailable' + else: + # The expected local IDs above are independent of the actual Host + # payload and plugin output, preserving session-vs-event provenance. + scope = ( + ['identity-instance', event.workspace_id, '00000000-0000-4000-8000-000000000007'] + if profile == 'shared' + else [event.workspace_id] + ) + encoded = json.dumps([scope, local_identity], ensure_ascii=False, separators=(',', ':')).encode() + expected = 'lb_' + hashlib.sha256(encoded).hexdigest() + assert helper(consumer, sdk) == expected diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 1404a1537..fa5fb18d1 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -80,6 +80,7 @@ class FakePluginConnector: self.results = results or [] self.error = error self.delay = delay + self.delay_entered = asyncio.Event() self.calls: list[dict] = [] self.contexts: list[dict] = [] self.sessions_during_run: list[dict | None] = [] @@ -100,6 +101,7 @@ class FakePluginConnector: for result in self.results: if self.delay: + self.delay_entered.set() await asyncio.sleep(self.delay) yield result @@ -142,6 +144,7 @@ class FakeApplication: get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase('kb_001')) ) self.skill_mgr = types.SimpleNamespace( + reload_skills=AsyncMock(), get_skills=lambda context: { 'demo': { 'name': 'demo', @@ -153,7 +156,7 @@ class FakeApplication: 'display_name': 'Hidden Skill', 'description': 'Not bound to this pipeline.', }, - } + }, ) @@ -600,6 +603,35 @@ async def test_orchestrator_stops_after_cancel_request(clean_agent_state): assert run['status_reason'] == 'user stopped' +@pytest.mark.asyncio +@pytest.mark.parametrize('cancel_while_waiting', [False, True]) +async def test_consumer_exit_finalizes_cancelled_ledger(clean_agent_state, cancel_while_waiting): + connector = FakePluginConnector( + results=[ + {'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'first'}}}, + {'type': 'run.completed', 'data': {'finish_reason': 'stop'}}, + ] + ) + orchestrator = AgentRunOrchestrator(FakeApplication(connector, clean_agent_state), FakeRegistry(make_descriptor())) + plan = orchestrator.query_bridge.build_plan(make_query()) + stream = orchestrator.run(plan.event, plan.binding, adapter_context={'_execution_context': TEST_CONTEXT}) + await anext(stream) + if cancel_while_waiting: + connector.delay = 10 + pending = asyncio.create_task(anext(stream)) + # Cancel the provider wait, not an arbitrary journal DB operation. + await asyncio.wait_for(connector.delay_entered.wait(), timeout=2) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + else: + await stream.aclose() + run = await RunLedgerStore(clean_agent_state).get_run(connector.contexts[0]['run_id']) + assert run['status'] == 'cancelled' + assert run['status_reason'] == 'consumer_cancelled' + assert await get_session_registry().list_active_runs() == [] + + @pytest.mark.asyncio async def test_orchestrator_does_not_package_query_messages_into_context(clean_agent_state): """Host should not build an agent working-context window from query.messages.""" @@ -757,6 +789,13 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event( assert [message.content for message in messages] == ['state saved'] # State is persisted to the database via PersistentStateStore. + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + + event = QueryEntryAdapter.query_to_event(query) + config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID) + binding = AgentBindingResolver().resolve_one(event, [config]) + snapshot = await get_persistent_state_store(db_engine).build_snapshot_from_event(event, binding, descriptor) + assert snapshot['conversation'].get('external.conversation_id') == 'external_conv_123' @pytest.mark.asyncio @@ -1007,7 +1046,7 @@ class TestQueryEntrySessionQueryId: @pytest.mark.asyncio async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state): - """Pure event-first flow has query_id=None in session.""" + """Pure event-first flow ignores the previous run's conversation pins.""" from langbot.pkg.agent.runner.host_models import ( AgentEventEnvelope, AgentBinding, @@ -1018,6 +1057,8 @@ class TestQueryEntrySessionQueryId: ) from langbot_plugin.api.entities.builtin.runner.input import AgentInput from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext + from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store + from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILL_NAMES_STATE_KEY, ACTIVATED_SKILLS_KEY db_engine = clean_agent_state descriptor = make_descriptor() @@ -1030,6 +1071,10 @@ class TestQueryEntrySessionQueryId: ] ) ap = FakeApplication(plugin_connector, db_engine) + revision = 'sha256:' + '1' * 64 + ap.skill_repository = types.SimpleNamespace( + get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision}) + ) async def build_resource_context(execution_query): from langbot.pkg.provider.tools.loaders.mcp import ( @@ -1085,10 +1130,21 @@ class TestQueryEntrySessionQueryId: 'mcp-resource-agent-read-enabled': True, }, resource_policy=ResourcePolicy(), - state_policy=StatePolicy(enable_state=False, state_scopes=[]), + state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']), delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True), ) + success, error = await get_persistent_state_store(db_engine).apply_update_from_event( + event.model_copy(update={'workspace_id': TEST_CONTEXT.workspace_uuid}), + binding, + descriptor, + 'conversation', + ACTIVATED_SKILL_NAMES_STATE_KEY, + [{'name': 'demo', 'revision': revision}], + None, + ) + assert success is True and error is None + messages = [ message async for message in orchestrator.run( @@ -1114,6 +1170,8 @@ class TestQueryEntrySessionQueryId: assert execution_query.message_event.type == event.event_type assert '_host_box_scope' not in execution_query.variables assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden'] + assert ACTIVATED_SKILLS_KEY not in execution_query.variables + ap.skill_repository.get_skill.assert_not_awaited() assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1' assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text'] @@ -1344,8 +1402,9 @@ class TestQueryEntryAdapterHostCapabilities: assert snapshot['conversation']['external.test_key'] == 'test_value' @pytest.mark.asyncio - async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state): - """Persisted activated skill names are restored into the next Query run.""" + @pytest.mark.parametrize('legacy_state', [False, True]) + async def test_new_query_does_not_restore_conversation_skill_pins(self, clean_agent_state, legacy_state): + """New runs ignore both revision and legacy name-only conversation pins.""" from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store from langbot.pkg.provider.tools.loaders.skill import ( ACTIVATED_SKILL_NAMES_STATE_KEY, @@ -1363,6 +1422,10 @@ class TestQueryEntryAdapterHostCapabilities: ] ) ap = FakeApplication(plugin_connector, db_engine) + revision = 'sha256:' + '1' * 64 + ap.skill_repository = types.SimpleNamespace( + get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision}) + ) orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) query = make_query() @@ -1376,7 +1439,7 @@ class TestQueryEntryAdapterHostCapabilities: descriptor, 'conversation', ACTIVATED_SKILL_NAMES_STATE_KEY, - ['demo'], + ['demo'] if legacy_state else [{'name': 'demo', 'revision': revision}], None, ) assert success is True @@ -1385,7 +1448,8 @@ class TestQueryEntryAdapterHostCapabilities: messages = [message async for message in orchestrator.run_from_query(query)] assert len(messages) == 1 - assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo' + assert ACTIVATED_SKILLS_KEY not in query.variables + ap.skill_repository.get_skill.assert_not_awaited() @pytest.mark.asyncio async def test_event_log_and_transcript_written(self, clean_agent_state): diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py index a4dbf3190..160c9f625 100644 --- a/tests/unit_tests/agent/test_platform_tools.py +++ b/tests/unit_tests/agent/test_platform_tools.py @@ -207,6 +207,19 @@ def test_platform_resources_require_runner_call_permission() -> None: assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}] +def test_interaction_resume_can_reply_only_to_a_frozen_delivery_target() -> None: + event = _event().model_copy(update={'event_type': 'interaction.submitted'}) + names = resolve_agent_platform_tool_names({}, event.event_type) + assert 'event_reply' in names + resources, _ = build_platform_tool_resources(event, names, ['detail', 'call']) + assert 'event_reply' in {item['tool_name'] for item in resources} + + event.delivery.reply_target = None + resources, capabilities = build_platform_tool_resources(event, names, ['detail', 'call']) + assert 'event_reply' not in {item['tool_name'] for item in resources} + assert {'name': 'event_reply', 'reason': 'event_target_unavailable'} in capabilities['unavailable_tools'] + + @pytest.mark.asyncio @pytest.mark.parametrize( ('name', 'params', 'api'), diff --git a/tests/unit_tests/agent/test_registry.py b/tests/unit_tests/agent/test_registry.py index da7780622..025d23f74 100644 --- a/tests/unit_tests/agent/test_registry.py +++ b/tests/unit_tests/agent/test_registry.py @@ -124,6 +124,7 @@ class TestRegistryDiscovery: async def test_discover_caches_results(self): """Discovery should cache results.""" ap = FakeApplication() + ap.plugin_connector.list_runners = AsyncMock(wraps=ap.plugin_connector.list_runners) registry = RunnerRegistry(ap) # First discovery @@ -134,6 +135,11 @@ class TestRegistryDiscovery: assert registry._cache is not None assert len(runners1) == len(runners2) + ap.plugin_connector.list_runners.assert_awaited_once() + assert [runner.id for runner in runners2] == [ + 'plugin:langbot-team/LocalAgent/default', + 'plugin:alice/my-agent/custom', + ] @pytest.mark.asyncio async def test_discover_handles_plugin_disabled(self): diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py index b4dbe0970..ca1bd0e39 100644 --- a/tests/unit_tests/agent/test_result_normalizer.py +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -156,6 +156,7 @@ class TestNormalizeRunCompleted: assert result is not None assert isinstance(result, provider_message.Message) + assert (result.role, result.content) == ('assistant', 'Final response') @pytest.mark.asyncio async def test_normalize_run_completed_without_message(self): diff --git a/tests/unit_tests/agent/test_run_journal.py b/tests/unit_tests/agent/test_run_journal.py index 8908681ba..c229318ca 100644 --- a/tests/unit_tests/agent/test_run_journal.py +++ b/tests/unit_tests/agent/test_run_journal.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock import pytest from langbot.pkg.agent.runner.run_journal import AgentRunJournal +from langbot.pkg.storage.media import MediaCache @pytest.mark.asyncio @@ -52,7 +53,37 @@ async def test_agent_run_snapshot_records_identity_and_redacts_inline_attachment assert raw_input['contents'][0]['image_base64'] == 'large-image-data' assert raw_input['attachments'][0]['content'] == 'large-file-data' - if event_type != 'message.received': - assert saved['metadata']['input_event'] == event.data - else: - assert 'input_event' not in saved['metadata'] + assert saved['metadata']['input_event'] == event.data + + +def test_event_snapshot_preserves_custom_values_and_redacts_nested_binary(): + data = { + 'member': {'id': 'u1', 'active': False}, + 'count': 0, + 'custom': {'contents': [{'type': 'image_base64', 'image_base64': 'secret'}]}, + 'attachments': [{'name': 'file.txt', 'content': 'secret'}], + } + saved = AgentRunJournal._sanitize_event_data(data) + assert saved['member'] == data['member'] + assert saved['count'] == 0 + assert saved['custom']['contents'][0]['image_base64'] is None + assert saved['attachments'][0]['content'] is None + assert data['custom']['contents'][0]['image_base64'] == 'secret' + + +@pytest.mark.parametrize( + 'image', + [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}}, + ], +) +def test_metadata_and_transcript_redaction_cover_all_image_encodings(image): + content = AgentRunJournal._sanitize_content_item(image) + event = AgentRunJournal._sanitize_event_data({'nested': [image]}) + assert not MediaCache.has_inline_images(content) + assert not MediaCache.has_inline_images(event) + assert content['content_redacted'] is True + assert event['nested'][0]['content_redacted'] is True + assert MediaCache.has_inline_images(image) diff --git a/tests/unit_tests/agent/test_run_ledger_api_auth.py b/tests/unit_tests/agent/test_run_ledger_api_auth.py index 992bbcdfd..62d390326 100644 --- a/tests/unit_tests/agent/test_run_ledger_api_auth.py +++ b/tests/unit_tests/agent/test_run_ledger_api_auth.py @@ -3,8 +3,9 @@ from __future__ import annotations import datetime +import json from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest import sqlalchemy @@ -17,6 +18,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry from langbot.pkg.entity.persistence import agent_run as agent_run_model from langbot.pkg.entity.persistence.base import Base from langbot.pkg.plugin.handler import RuntimeConnectionHandler +from langbot.pkg.storage.media import MediaCache from langbot_plugin.api.entities.builtin.runner.run_ledger import ( AgentRun, AgentRunEvent, @@ -173,6 +175,22 @@ async def _create_run( ) +@pytest.fixture +async def ledger_snapshot(db_engine): + await _create_run( + db_engine, run_id='workspace_guard', workspace_id='workspace_guard', plugin_identity='guard/runner' + ) + + async def snapshot(): + async with AsyncSession(db_engine) as session: + return [ + (await session.execute(sqlalchemy.select(*model.__table__.c).order_by(model.id))).mappings().all() + for model in (agent_run_model.AgentRun, agent_run_model.AgentRunEvent) + ] + + return snapshot + + @pytest.mark.asyncio async def test_run_get_returns_current_run(session_registry, db_engine): await _register_session(session_registry, available_apis={'run_get': True}) @@ -262,6 +280,7 @@ async def test_run_list_filters_same_scope_different_runner_owner(session_regist async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner( session_registry, db_engine, + ledger_snapshot, ): await _register_session( session_registry, @@ -349,10 +368,12 @@ async def test_non_admin_target_run_actions_reject_same_scope_different_runner_o ), ] + before = await ledger_snapshot() for action, payload in calls: result = await handler.actions[action](payload) assert result.code != 0, action assert 'not accessible' in result.message.lower() + assert await ledger_snapshot() == before @pytest.mark.asyncio @@ -1046,11 +1067,56 @@ async def test_run_append_result_basic_path(session_registry, db_engine): @pytest.mark.asyncio -async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine): +async def test_run_append_result_uses_cas_and_redacts_metadata(session_registry, db_engine): + await _register_session(session_registry, available_apis={'run_append_result': True}) + await _create_run(db_engine) + handler = _handler(db_engine) + objects = {} + + async def save(key, data): + objects[key] = data + + storage = SimpleNamespace( + storage_provider=SimpleNamespace(exists=AsyncMock(return_value=False), save=AsyncMock(side_effect=save)) + ) + storage.media_cache = MediaCache(handler.ap, storage) + handler.ap.storage_mgr = storage + image = {'type': 'Image', 'base64': 'eA=='} + result = await handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]( + { + 'run_id': 'run_1', + 'caller_plugin_identity': 'test/runner', + 'sequence': 2, + 'event_type': 'tool.call.completed', + 'data': {'tool_call_id': 'call_1', 'tool_name': 'image', 'result': {'images': [image]}}, + 'metadata': {'preview': image}, + } + ) + assert result.code == 0 + event = AgentRunEvent.model_validate(result.data) + saved = event.data['result']['images'][0] + assert saved['base64'] is None + assert saved['url'].startswith('/api/v1/files/media/') + assert objects == {saved['storage_key']: b'x'} + async with db_engine.connect() as conn: + metadata = ( + await conn.execute( + sqlalchemy.select(agent_run_model.AgentRunEvent.metadata_json).where( + agent_run_model.AgentRunEvent.sequence == 2 + ) + ) + ).scalar_one() + assert json.loads(metadata)['preview']['base64'] is None + assert image['base64'] == 'eA==' + + +@pytest.mark.asyncio +async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1066,14 +1132,16 @@ async def test_run_append_result_rejects_side_effecting_result_type(session_regi assert result.code != 0 assert 'canonical runner result path' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine): +async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1089,14 +1157,16 @@ async def test_run_append_result_rejects_unknown_result_type(session_registry, d assert result.code != 0 assert 'unknown result type' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_result_validates_known_payloads(session_registry, db_engine): +async def test_run_append_result_validates_known_payloads(session_registry, db_engine, ledger_snapshot): await _register_session(session_registry, available_apis={'run_append_result': True}) await _create_run(db_engine) handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] + before = await ledger_snapshot() result = await run_append_result( { @@ -1112,10 +1182,13 @@ async def test_run_append_result_validates_known_payloads(session_registry, db_e assert result.code != 0 assert 'invalid action.requested payload' in result.message + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine): +async def test_run_append_and_finalize_claimed_target_require_active_claim( + session_registry, db_engine, ledger_snapshot +): await _register_session( session_registry, available_apis={'run_append_result': True, 'run_finalize': True}, @@ -1139,6 +1212,7 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi handler = _handler(db_engine) run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value] run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value] + before = await ledger_snapshot() missing_claim = await run_append_result( { @@ -1152,6 +1226,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi }, } ) + assert missing_claim.code != 0 + assert 'active claim ownership' in missing_claim.message + assert await ledger_snapshot() == before wrong_claim = await run_finalize( { 'run_id': 'run_1', @@ -1162,6 +1239,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi 'status': 'completed', } ) + assert wrong_claim.code != 0 + assert 'claim ownership is not active' in wrong_claim.message + assert await ledger_snapshot() == before appended = await run_append_result( { 'run_id': 'run_1', @@ -1187,10 +1267,6 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi } ) - assert missing_claim.code != 0 - assert 'active claim ownership' in missing_claim.message - assert wrong_claim.code != 0 - assert 'claim ownership is not active' in wrong_claim.message assert appended.code == 0 assert finalized.code == 0 assert finalized.data['status'] == 'completed' @@ -1425,7 +1501,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation( @pytest.mark.asyncio -async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine): +async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine, ledger_snapshot): await _register_session( session_registry, available_apis={'run_renew_claim': True, 'run_release_claim': True}, @@ -1447,6 +1523,7 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg handler = _handler(db_engine) run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value] run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value] + before = await ledger_snapshot() renewed = await run_renew_claim( { @@ -1457,6 +1534,9 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg 'claim_token': claim['claim_token'], } ) + assert renewed.code != 0 + assert 'not accessible' in renewed.message.lower() + assert await ledger_snapshot() == before released = await run_release_claim( { 'run_id': 'run_1', @@ -1467,14 +1547,13 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg } ) - assert renewed.code != 0 - assert 'not accessible' in renewed.message.lower() assert released.code != 0 assert 'not accessible' in released.message.lower() + assert await ledger_snapshot() == before @pytest.mark.asyncio -async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine): +async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine, ledger_snapshot): await _register_session( session_registry, available_apis={'run_claim': True, 'run_release_claim': True}, @@ -1503,6 +1582,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_ } ) assert claimed.code == 0 + before = await ledger_snapshot() released = await run_release_claim( { @@ -1517,3 +1597,4 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_ assert released.code != 0 assert 'use run_finalize' in released.message + assert await ledger_snapshot() == before diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py index 4359d87c5..2195f86f7 100644 --- a/tests/unit_tests/agent/test_run_ledger_store.py +++ b/tests/unit_tests/agent/test_run_ledger_store.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +import json import pytest import sqlalchemy @@ -36,6 +37,81 @@ def store(db_engine): return RunLedgerStore(db_engine) +@pytest.fixture +async def legacy_run(store, db_engine): + await store.create_run( + run_id='legacy', + event_id='event', + binding_id='binding', + runner_id='runner', + workspace_id='ws', + ) + metadata = { + 'input': [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA==', 'detail': 'low'}}, + {'type': 'image_url', 'image_url': {'url': 'https://example.com/image.png'}}, + {'type': 'text', 'text': 'keep this'}, + ], + 'attempt': 0, + 'activated_skills': {'existing': 'v1'}, + } + async with db_engine.begin() as conn: + # Explicit TEXT binding simulates data persisted before the media contract. + await conn.execute( + sqlalchemy.update(AgentRun) + .where(AgentRun.run_id == 'legacy') + .values( + metadata_json=sqlalchemy.bindparam('legacy_metadata', json.dumps(metadata), type_=sqlalchemy.Text()) + ) + ) + return metadata + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['pin', 'finalize']) +async def test_metadata_update_redacts_legacy_images(store, legacy_run, operation): + if operation == 'pin': + assert await store.pin_skill_revision('legacy', 'ws', 'demo', 'v2') == 'v2' + else: + await store.finalize_run(run_id='legacy', status='completed', metadata={'note': 'done'}) + + run = await store.get_run('legacy') + expected = { + **legacy_run, + 'input': [ + {'type': 'Image', 'base64': None}, + {'type': 'image_url', 'image_url': {'url': None, 'detail': 'low'}}, + *legacy_run['input'][2:], + ], + } + if operation == 'pin': + expected['activated_skills'] = {'existing': 'v1', 'demo': 'v2'} + assert run['status'] == 'running' + else: + expected['note'] = 'done' + assert run['status'] == 'completed' + assert run['metadata'] == expected + + +@pytest.mark.asyncio +async def test_finalize_without_metadata_preserves_legacy_snapshot(store, legacy_run): + run = await store.finalize_run(run_id='legacy', status='completed') + assert run['status'] == 'completed' + assert run['metadata'] == legacy_run + + +@pytest.mark.asyncio +async def test_metadata_update_still_rejects_new_inline_images(store, legacy_run): + with pytest.raises(sqlalchemy.exc.StatementError, match='Inline images cannot be persisted'): + await store.finalize_run( + run_id='legacy', status='completed', metadata={'new_image': {'type': 'Image', 'base64': 'eQ=='}} + ) + run = await store.get_run('legacy') + assert run['status'] == 'running' + assert run['metadata'] == legacy_run + + @pytest.mark.asyncio async def test_create_queued_run_claim_renew_release(store): run = await store.create_run( @@ -499,3 +575,91 @@ async def test_agent_run_pages_include_old_bindings_without_leaking_other_agents page, cursor, more, total = await store.list_runs(agent_id='one', workspace_id='w', before_id=cursor, limit=2) assert [run['run_id'] for run in page] == ['old'] assert not more and total == 3 + + +@pytest.mark.asyncio +async def test_skill_pins_belong_to_run_and_survive_store_restart(store, db_engine): + for run_id in ('old', 'new'): + await store.create_run( + run_id=run_id, + event_id=run_id, + binding_id='binding', + runner_id='runner', + workspace_id='ws', + conversation_id='same', + status='running', + ) + assert await store.pin_skill_revision('old', 'ws', 'demo', 'v1') == 'v1' + restarted = RunLedgerStore(db_engine) + assert await restarted.pin_skill_revision('old', 'ws', 'demo', 'v2') == 'v1' + assert await restarted.pin_skill_revision('new', 'ws', 'demo', 'v2') == 'v2' + assert (await restarted.get_run('old'))['metadata']['activated_skills'] == {'demo': 'v1'} + with pytest.raises(ValueError, match='current Workspace'): + await restarted.pin_skill_revision('old', 'other', 'demo', 'v2') + await store.finalize_run(run_id='old', status='completed') + with pytest.raises(ValueError, match='active run'): + await restarted.pin_skill_revision('old', 'ws', 'demo', 'v2') + + +@pytest.mark.asyncio +async def test_activation_uses_new_revision_only_for_new_run(store, db_engine, tmp_path): + from types import SimpleNamespace + from unittest.mock import Mock + from langbot.pkg.api.http.context import ExecutionContext + from langbot.pkg.skill.repository import SkillRepository + from langbot.pkg.skill.manager import SkillManager + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader + + context = ExecutionContext(instance_uuid='instance', workspace_uuid='ws', placement_generation=1) + + async def binding(workspace_uuid, **kwargs): + return context + + app = SimpleNamespace( + logger=Mock(), + instance_config=SimpleNamespace(data={'skills': {'root': str(tmp_path / 'skills')}}), + workspace_service=SimpleNamespace(get_execution_binding=binding), + persistence_mgr=SimpleNamespace(get_db_engine=lambda: db_engine), + ) + app.skill_repository = SkillRepository(app) + app.skill_mgr = SkillManager(app) + loader = SkillToolLoader(app) + first = await app.skill_repository.create_skill(context, {'name': 'demo', 'instructions': 'version one'}) + await app.skill_mgr.reload_skills(context) + + def query(run_id): + return SimpleNamespace( + instance_uuid='instance', + workspace_uuid='ws', + placement_generation=1, + variables={'_pipeline_bound_skills': ['demo']}, + _execution_context=context, + _agent_run_session={'run_id': run_id}, + ) + + for name in ('old', 'new'): + await store.create_run( + run_id=name, + event_id=name, + binding_id='binding', + runner_id='runner', + workspace_id='ws', + conversation_id='same', + status='running', + ) + old = query('old') + assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, old))['content'] + second = await app.skill_repository.update_skill( + context, 'demo', {'instructions': 'version two'}, base_revision=first['revision'] + ) + await app.skill_mgr.reload_skills(context) + assert 'version two' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, query('new')))['content'] + assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, old))['content'] + await app.skill_repository.delete_skill(context, 'demo') + await app.skill_mgr.reload_skills(context) + assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, query('old')))['content'] + assert (await store.get_run('new'))['metadata']['activated_skills']['demo'] == second['revision'] + revoked = query('old') + revoked.variables['_pipeline_bound_skills'] = [] + with pytest.raises(ValueError, match='not authorized'): + await loader._invoke_activate_skill({'skill_name': 'demo'}, revoked) diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py index a92c6a4af..8f9e758f1 100644 --- a/tests/unit_tests/agent/test_state_store.py +++ b/tests/unit_tests/agent/test_state_store.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import os import tempfile +from copy import deepcopy import pytest from sqlalchemy.ext.asyncio import create_async_engine @@ -132,6 +133,24 @@ class TestStateScopeHelpers: assert keys['subject'].startswith('subject:v2:') assert keys['runner'].startswith('runner:v2:') assert len(set(keys.values())) == len(keys) + for dimension, value in [ + ('workspace_id', 'ws_other'), + ('bot_id', 'bot_other'), + ]: + other = deepcopy(event) + setattr(other, dimension, value) + for scope in VALID_STATE_SCOPES: + assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope] + for scope, dimension, value in [ + ('conversation', 'conversation_id', 'conv_other'), + ('conversation', 'thread_id', 'thread_other'), + ('actor', 'actor_id', 'user_other'), + ('subject', 'subject_id', 'msg_other'), + ]: + other = deepcopy(event) + owner = other.actor if scope == 'actor' else other.subject if scope == 'subject' else other + setattr(owner, dimension, value) + assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope] def test_scope_key_missing_identity_returns_none(self): descriptor = make_descriptor() diff --git a/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py index 623822379..d0c58c36a 100644 --- a/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py +++ b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py @@ -294,7 +294,7 @@ async def test_cross_workspace_parent_references_are_rejected(tenant_services): { 'event_bindings': [ { - 'event_pattern': 'message.*', + 'event_pattern': 'message.received', 'target_type': 'pipeline', 'target_uuid': 'pipeline-b', } diff --git a/tests/unit_tests/api/http/test_entitlement_context.py b/tests/unit_tests/api/http/test_entitlement_context.py index 600eb1082..56232c999 100644 --- a/tests/unit_tests/api/http/test_entitlement_context.py +++ b/tests/unit_tests/api/http/test_entitlement_context.py @@ -76,3 +76,21 @@ def test_execution_context_preserves_entitlement_revision(): ) assert ExecutionContext.from_request(request).entitlement_revision == 11 + + +@pytest.mark.asyncio +async def test_handler_entitlement_denial_is_403_not_internal_error(): + from langbot.pkg.api.http.controller.group import AuthType + from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError + + router = _router(SimpleNamespace(mode='cloud')) + router.path = '/policy' + router.name = 'policy' + + @router.route('', methods=['GET'], auth_type=AuthType.NONE) + async def denied(): + raise EntitlementFeatureUnavailableError('platform.telegram', entitlement_revision=2) + + response = await router.quart_app.test_client().get('/policy') + assert response.status_code == 403 + assert (await response.get_json())['code'] == 'entitlement_unavailable' diff --git a/tests/unit_tests/api/http/test_route_tenant_scope.py b/tests/unit_tests/api/http/test_route_tenant_scope.py index 4b4cb96e6..26e5ae271 100644 --- a/tests/unit_tests/api/http/test_route_tenant_scope.py +++ b/tests/unit_tests/api/http/test_route_tenant_scope.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from langbot.pkg.api.http.controller import group from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata pytestmark = pytest.mark.asyncio @@ -20,6 +21,8 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa release = asyncio.Event() observations: list[bool] = [] engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as connection: + await connection.run_sync(WorkspaceMetadata.__table__.create) persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME) persistence.db = SimpleNamespace(get_engine=lambda: engine) @@ -60,7 +63,7 @@ async def test_authenticated_route_does_not_hold_database_session_during_externa ) ) try: - await entered.wait() + await asyncio.wait_for(entered.wait(), timeout=5) assert observations == [True] release.set() response = await request diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index 600c3e6d3..f3916d829 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -13,6 +13,7 @@ from langbot.pkg.api.http.service.agent import ( PIPELINE_EVENT_PATTERNS, AgentService, ) +from langbot.pkg.api.http.service.model import LLMModelsService pytestmark = pytest.mark.asyncio @@ -82,6 +83,7 @@ def _compiled_update_values(statement): def _make_app(): app = SimpleNamespace() + app.llm_model_service = LLMModelsService(app) app.persistence_mgr = SimpleNamespace( execute_async=AsyncMock(), serialize_model=Mock(side_effect=_serialize_agent), @@ -866,6 +868,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters( service = AgentService(app) with pytest.raises(ValueError, match='Select an installed'): await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'invalid:a/b/c'}) + app.persistence_mgr.execute_async.assert_not_awaited() + app.runner_registry.get.assert_not_awaited() app.runner_registry = SimpleNamespace( get=AsyncMock( return_value=SimpleNamespace( @@ -877,7 +881,11 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters( ) ) with pytest.raises(ValueError, match='Required processor parameter'): - await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'}) + await service.create_agent( + WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c', 'parameters': {}} + ) + app.persistence_mgr.execute_async.assert_not_awaited() + app.runner_registry.get.assert_awaited_once_with(WORKSPACE_UUID, 'plugin:a/b/c') async def test_unavailable_event_processor_can_still_be_renamed(): diff --git a/tests/unit_tests/api/service/test_apikey_service.py b/tests/unit_tests/api/service/test_apikey_service.py index 160ffe430..85bc461c3 100644 --- a/tests/unit_tests/api/service/test_apikey_service.py +++ b/tests/unit_tests/api/service/test_apikey_service.py @@ -347,11 +347,6 @@ class TestApiKeyServiceVerifyApiKey: assert await service.verify_api_key('lbk_invalid_key') is False - async def test_verify_api_key_empty_string(self, api_key_context): - _application, service, _context, _engine = api_key_context - - assert await service.verify_api_key('') is False - async def test_verify_api_key_unknown_key(self, api_key_context): _application, service, _context, _engine = api_key_context diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index 81a180a75..574f28d7b 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -11,12 +11,15 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch from types import SimpleNamespace +import hashlib +import hmac import json import sqlalchemy import uuid from langbot.pkg.api.http.service.bot import BotService from langbot.pkg.entity.persistence.bot import Bot +from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.asyncio @@ -182,6 +185,11 @@ class TestBotServiceGetBot: assert result is not None assert result['uuid'] == 'test-uuid' assert result['name'] == 'Found Bot' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID) + ) + ap.persistence_mgr.serialize_model.assert_called_once_with(Bot, bot, ['adapter_config']) async def test_get_bot_by_uuid_not_found(self): """Returns None when bot not found.""" @@ -361,6 +369,8 @@ class TestBotServiceCreateBot: # Execute & Verify with pytest.raises(ValueError, match='Maximum number of bots'): await service.create_bot(WORKSPACE_UUID, {'name': 'New Bot'}) + assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list) + ap.platform_mgr.load_bot.assert_not_awaited() async def test_create_bot_no_limit(self): """Creates bot without limit check when max_bots=-1.""" @@ -390,14 +400,14 @@ class TestBotServiceCreateBot: async def mock_execute(query): nonlocal call_count call_count += 1 - if call_count <= 2: - return pipeline_result # First call: check pipeline - elif call_count == 3: - return Mock() # Insert + if call_count == 1: + return Mock() # Insert; there is no legacy pipeline lookup 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', 'adapter': 'telegram', 'enable': False} + ) service = BotService(ap) @@ -408,7 +418,22 @@ class TestBotServiceCreateBot: # Verify assert bot_uuid is not None - assert len(bot_uuid) == 36 # UUID format + assert uuid.UUID(bot_uuid).version == 4 + statement = ap.persistence_mgr.execute_async.await_args_list[0].args[0] + assert statement.is_insert + assert statement.table.name == Bot.__tablename__ + values = statement.compile().params + expected = { + 'uuid': bot_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Bot', + 'adapter': 'telegram', + 'adapter_config': {}, + 'event_bindings': [], + 'plugin_processors': [], + } + assert {key: values.get(key) for key in expected} == expected + ap.platform_mgr.load_bot.assert_not_awaited() async def test_create_bot_scopes_insert_without_legacy_pipeline_fields(self): """Creates a Workspace-owned Bot without restoring removed pipeline columns.""" @@ -647,33 +672,35 @@ class TestBotServiceDeleteBot: await service.delete_bot(WORKSPACE_UUID, 'test-uuid') # Verify - ap.platform_mgr.remove_bot.assert_called_once_with(WORKSPACE_UUID, 'test-uuid') - ap.persistence_mgr.execute_async.assert_called_once() + ap.platform_mgr.remove_bot.assert_awaited_once_with(WORKSPACE_UUID, 'test-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_delete + assert statement.table.name == Bot.__tablename__ + assert statement.whereclause.compare( + sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID) + ) async def test_delete_bot_nonexistent_uuid(self): - """Delete operation completes even for nonexistent UUID.""" + """A missing persisted Bot is rejected before removing its runtime.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0)) ap.platform_mgr = SimpleNamespace() ap.platform_mgr.remove_bot = AsyncMock() service = BotService(ap) - service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'}) - - # Execute - should not raise - await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid') - - # Verify - both called regardless - ap.platform_mgr.remove_bot.assert_called_once() + with pytest.raises(WorkspaceNotFoundError, match='Bot not found'): + await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid') + ap.platform_mgr.remove_bot.assert_not_awaited() class TestBotServiceListEventLogs: """Tests for list_event_logs method.""" - async def test_list_event_logs_bot_not_found_raises(self): - """Raises Exception when runtime bot not found.""" + async def test_list_event_logs_inactive_bot_returns_empty(self): + """A saved bot without an active runtime has no event logs.""" # Setup ap = SimpleNamespace() ap.platform_mgr = SimpleNamespace() @@ -683,8 +710,7 @@ class TestBotServiceListEventLogs: service.get_bot = AsyncMock(return_value={'uuid': 'nonexistent-uuid'}) # Execute & Verify - with pytest.raises(Exception, match='Bot not found'): - await service.list_event_logs(WORKSPACE_UUID, 'nonexistent-uuid', 0, 10) + assert await service.list_event_logs(WORKSPACE_UUID, 'nonexistent-uuid', 0, 10) == ([], 0) async def test_list_event_logs_returns_logs(self): """Returns logs from runtime bot logger.""" @@ -812,7 +838,9 @@ class TestBotServiceHttpBotInboundTest: assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}] headers = request.kwargs['headers'] assert headers['X-LB-Timestamp'] - assert headers['X-LB-Signature'].startswith('sha256=') + signing_input = headers['X-LB-Timestamp'].encode() + b'.' + request.kwargs['data'] + digest = hmac.new(b'test-secret', signing_input, hashlib.sha256).hexdigest() + assert headers['X-LB-Signature'] == f'sha256={digest}' async def test_rejects_non_http_bot(self): service = BotService(SimpleNamespace()) @@ -868,7 +896,8 @@ class TestBotServiceSendMessage: with pytest.raises(Exception, match='Invalid message_chain format'): await service.send_message(WORKSPACE_UUID, 'bot-uuid', 'group', '123', {'invalid': 'format'}) - async def test_send_message_valid_call(self): + @pytest.mark.parametrize(('target_type', 'target_id'), [('group', 123), ('person', 'user-456')]) + async def test_send_message_valid_call(self, target_type, target_id): """Sends message through adapter when all valid.""" # Setup ap = SimpleNamespace() @@ -882,14 +911,18 @@ class TestBotServiceSendMessage: service = BotService(ap) service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'}) - # Execute with valid message chain format - message_chain_data = {'messages': [{'type': 'text', 'data': {'text': 'Hello'}}]} + from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain - # Patch the import location - the module imports inside the function - with patch('langbot_plugin.api.entities.builtin.platform.message.MessageChain') as MockMessageChain: - mock_chain = Mock() - MockMessageChain.model_validate = Mock(return_value=mock_chain) - await service.send_message(WORKSPACE_UUID, 'bot-uuid', 'group', '123', message_chain_data) + expected_chain = MessageChain([Plain(text='Hello')]) + message_chain_data = expected_chain.model_dump() + await service.send_message(WORKSPACE_UUID, 'bot-uuid', target_type, target_id, message_chain_data) - # Verify adapter.send_message was called - runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain) + service.get_bot.assert_awaited_once_with(WORKSPACE_UUID, 'bot-uuid', include_secret=False) + ap.platform_mgr.get_bot_by_uuid.assert_awaited_once_with(WORKSPACE_UUID, 'bot-uuid') + runtime_bot.adapter.send_message.assert_awaited_once() + sent_target_type, sent_target_id, sent_chain = runtime_bot.adapter.send_message.await_args.args + assert sent_target_type == target_type + assert sent_target_id == str(target_id) + assert isinstance(sent_chain, MessageChain) + assert sent_chain.model_dump() == expected_chain.model_dump() + assert str(sent_chain) == 'Hello' diff --git a/tests/unit_tests/api/service/test_cli_diagnostics.py b/tests/unit_tests/api/service/test_cli_diagnostics.py index 95b85e709..a78965f5b 100644 --- a/tests/unit_tests/api/service/test_cli_diagnostics.py +++ b/tests/unit_tests/api/service/test_cli_diagnostics.py @@ -236,6 +236,8 @@ async def test_extension_discovery_preserves_empty_bindings_and_redacts_plugins( ap.plugin_connector.require_workspace_context.assert_awaited_once_with(request_context()) +@pytest.mark.compiled_cli +@pytest.mark.contract('cli_http') async def test_compiled_cli_against_core_http(service, tmp_path): # noqa: F811 """Optional cross-repo check: real HTTP/SQLite/Pipeline, synthetic runtime providers.""" import json diff --git a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py index 62ed06049..710cbf88c 100644 --- a/tests/unit_tests/api/service/test_cloud_managed_model_protection.py +++ b/tests/unit_tests/api/service/test_cloud_managed_model_protection.py @@ -73,7 +73,7 @@ async def test_all_model_types_reject_creation_under_managed_provider( with pytest.raises(ValueError, match='managed by Cloud'): await getattr(service, create_method)(WORKSPACE, model_data) - guard.assert_awaited_once() + guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER) @pytest.mark.parametrize( @@ -109,4 +109,4 @@ async def test_all_model_types_reject_update_and_delete_for_managed_provider( with pytest.raises(ValueError, match='managed by Cloud'): await getattr(service, write_method)(*args) - guard.assert_awaited_once() + guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER) diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py index 908774892..1dc3496ca 100644 --- a/tests/unit_tests/api/service/test_knowledge_service.py +++ b/tests/unit_tests/api/service/test_knowledge_service.py @@ -390,6 +390,7 @@ class TestGetKnowledgeBase: result = await KnowledgeService(app).get_knowledge_base(CONTEXT, 'kb1') assert result == {'uuid': 'kb1', 'name': 'KB1'} + app.rag_mgr.get_knowledge_base_details.assert_awaited_once_with(CONTEXT, 'kb1') @pytest.mark.asyncio async def test_returns_none_when_not_found(self): @@ -555,7 +556,11 @@ class TestListParsers: result = await KnowledgeService(app).list_parsers(CONTEXT) - assert len(result) == 2 + assert result == [ + {'id': 'parser1', 'supported_mime_types': ['text/plain']}, + {'id': 'parser2', 'supported_mime_types': ['application/pdf']}, + ] + app.plugin_connector.list_parsers.assert_awaited_once_with() @pytest.mark.asyncio async def test_filters_by_mime_type(self): @@ -589,7 +594,8 @@ class TestGetEngineSchemas: 'author/engine', ) - assert 'properties' in result + assert result == {'properties': {'name': {'type': 'string'}}} + app.plugin_connector.get_rag_creation_schema.assert_awaited_once_with('author/engine') @pytest.mark.asyncio async def test_returns_retrieval_schema(self): @@ -601,7 +607,8 @@ class TestGetEngineSchemas: 'author/engine', ) - assert 'properties' in result + assert result == {'properties': {'top_k': {'type': 'integer'}}} + app.plugin_connector.get_rag_retrieval_schema.assert_awaited_once_with('author/engine') @pytest.mark.asyncio async def test_reports_schema_runtime_exception(self): diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py index b4b00e6fb..77d6dbe90 100644 --- a/tests/unit_tests/api/service/test_maintenance_service.py +++ b/tests/unit_tests/api/service/test_maintenance_service.py @@ -14,8 +14,9 @@ Source: src/langbot/pkg/api/http/service/maintenance.py from __future__ import annotations import contextlib +import os import pytest -from unittest.mock import AsyncMock, Mock, patch, MagicMock +from unittest.mock import AsyncMock, Mock, patch, MagicMock, call from types import SimpleNamespace import datetime from pathlib import Path @@ -91,6 +92,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7) + service._cleanup_expired_log_files.assert_called_once_with(3) # Verify - returns counts assert 'uploaded_files' in result @@ -128,6 +131,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 14) + service._cleanup_expired_log_files.assert_called_once_with(7) # Verify assert result['uploaded_files'] == 2 @@ -152,7 +157,10 @@ class TestMaintenanceServiceCleanupExpiredFiles: service = MaintenanceService(ap) # Mock the internal cleanup methods - service._cleanup_expired_uploaded_files = AsyncMock(return_value=1) + service._expired_s3_upload_candidates = AsyncMock(return_value=[{'key': 'scoped/expired-upload'}]) + protected = Mock() + protected.scalars.return_value.all.return_value = [] + ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=protected)) service._cleanup_expired_log_files = Mock(return_value=0) # NOT async # Execute @@ -161,6 +169,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Verify assert result['uploaded_files'] == 1 assert result['log_files'] == 0 + service._expired_s3_upload_candidates.assert_awaited_once_with(TEST_CONTEXT, 7) + s3_provider.delete.assert_awaited_once_with('scoped/expired-upload') async def test_cleanup_expired_files_invalid_retention(self): """Uses default for invalid retention config.""" @@ -192,6 +202,8 @@ class TestMaintenanceServiceCleanupExpiredFiles: # Execute result = await service.cleanup_expired_files(TEST_CONTEXT) + service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7) + service._cleanup_expired_log_files.assert_called_once_with(3) # Verify - warning logged, defaults used assert ap.logger.warning.called @@ -280,7 +292,25 @@ class TestMaintenanceServiceGetStorageAnalysis: assert 'cleanup_policy' in result assert 'sections' in result assert 'database' in result - assert 'cleanup_candidates' in result + assert result['cleanup_policy'] == {'uploaded_file_retention_days': 7, 'log_retention_days': 3} + assert { + section['key']: (Path(section['path']).as_posix(), section['size_bytes'], section['file_count']) + for section in result['sections'] + } == { + 'database': ('data/langbot.db', 1000, 5), + 'logs': ('data/logs', 1000, 5), + 'storage': ('data/storage', 1000, 5), + 'vector_store': ('data/chroma', 1000, 5), + 'temp': ('data/temp', 1000, 5), + } + assert result['total_size_bytes'] == 5000 + assert result['database'] == { + 'type': 'sqlite', + 'monitoring_counts': {'messages': 10, 'errors': 0}, + 'binary_storage': {'count': 5, 'size_bytes': 500}, + } + assert result['cleanup_candidates'] == {'uploaded_files': [], 'log_files': []} + assert result['tasks'] == {'running': 0} async def test_get_storage_analysis_sections(self): """Returns all storage sections.""" @@ -383,6 +413,7 @@ class TestMaintenanceServiceGetStorageAnalysis: handler=plugin_handler, ), box_service=box_service, + skill_repository=SimpleNamespace(storage_root=Mock(return_value='/core/skills/tenants/test')), ) service = MaintenanceService(ap) service._path_size = Mock(return_value=10) @@ -394,12 +425,16 @@ class TestMaintenanceServiceGetStorageAnalysis: result = await service.get_storage_analysis(TEST_CONTEXT) - assert result['total_size_bytes'] == 115 + assert result['total_size_bytes'] == 125 + assert next(section for section in result['sections'] if section['key'] == 'skills')['path'] == str( + Path('/core/skills/tenants/test') + ) assert result['processes'][1]['directories'][0]['key'] == 'artifacts' assert result['processes'][2]['directories'][1]['key'] == 'mcp' assert result['processes'][2]['managed_processes'] == 2 plugin_handler.get_storage_analysis.assert_awaited_once_with() box_service.get_storage_analysis.assert_awaited_once_with(TEST_CONTEXT) + ap.skill_repository.storage_root.assert_called_once_with(TEST_CONTEXT) async def test_get_storage_analysis_postgresql(self): """Handles PostgreSQL database type.""" @@ -457,8 +492,12 @@ class TestMaintenanceServiceGetStorageAnalysis: result = await service.get_storage_analysis(TEST_CONTEXT) # Verify - assert len(result['cleanup_candidates']['uploaded_files']) == 1 - assert len(result['cleanup_candidates']['log_files']) == 1 + assert result['cleanup_candidates'] == { + 'uploaded_files': [{'key': 'old_file', 'size_bytes': 100}], + 'log_files': [{'name': 'old_log', 'size_bytes': 50}], + } + service._expired_uploaded_candidates.assert_awaited_once_with(TEST_CONTEXT, 7) + service._expired_log_candidates.assert_called_once_with(3) class TestMaintenanceServiceMonitoringCounts: @@ -479,12 +518,9 @@ class TestMaintenanceServiceMonitoringCounts: result = await service._monitoring_counts(TEST_CONTEXT) # Verify - all table keys present - assert 'messages' in result - assert 'llm_calls' in result - assert 'embedding_calls' in result - assert 'errors' in result - assert 'sessions' in result - assert 'feedback' in result + assert result == dict.fromkeys( + ['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 42 + ) async def test_monitoring_counts_zero_results(self): """Returns zero counts when tables empty.""" @@ -501,7 +537,9 @@ class TestMaintenanceServiceMonitoringCounts: result = await service._monitoring_counts(TEST_CONTEXT) # Verify - all zero - assert all(v == 0 for v in result.values()) + assert result == dict.fromkeys( + ['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 0 + ) class TestMaintenanceServiceBinaryStorageStats: @@ -568,7 +606,7 @@ class TestMaintenanceServiceBinaryStorageStats: # Verify - warning logged, size_bytes None or 0 assert ap.logger.warning.called - assert result['count'] == 5 + assert result == {'count': 5, 'size_bytes': None} class TestMaintenanceServicePathSize: @@ -783,11 +821,17 @@ class TestMaintenanceServiceIsUploadedFileKey: service = MaintenanceService(ap) # Execute - simple filename without path - key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt' + ap.storage_mgr.scoped_prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{owner_type}/') + ap.storage_mgr.is_scoped_object_key = Mock(return_value=True) + key = 'scoped/upload/uploaded_file.txt' result = service._is_uploaded_file_key(TEST_CONTEXT, key) # Verify assert result is True + assert ap.storage_mgr.scoped_prefix.call_args_list == [ + call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload') + ] + ap.storage_mgr.is_scoped_object_key.assert_called_once_with(key, expected_owner_type='upload') def test_is_uploaded_file_key_with_path(self): """Returns False for key with path separator.""" @@ -902,7 +946,7 @@ class TestMaintenanceServiceExpiredLogCandidates: result = service._expired_log_candidates(3, include_paths=True) # Verify - path included - assert 'path' in result[0] + assert result[0]['path'] == '/data/logs/' + old_log_name class TestMaintenanceServiceExpiredLocalUploadCandidates: @@ -923,40 +967,38 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates: # Verify assert result == [] - def test_expired_local_upload_candidates_filters_uploaded(self): - """Only returns uploaded files matching pattern.""" - # Setup - ap = SimpleNamespace() - ap.logger = SimpleNamespace() - ap.storage_mgr = _scoped_storage_manager() + def test_expired_local_upload_candidates_filters_uploaded(self, tmp_path, monkeypatch): + """Only expired files below this Workspace's upload roots are candidates.""" + monkeypatch.chdir(tmp_path) + prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{context.workspace_uuid}/{owner_type}/') + ap = SimpleNamespace(logger=SimpleNamespace(warning=Mock()), storage_mgr=SimpleNamespace(scoped_prefix=prefix)) + storage_root = Path('data/storage') + upload_root = storage_root / 'scoped/test-workspace/upload' + upload_root.mkdir(parents=True) + old = upload_root / 'valid_upload.txt' + old.write_bytes(b'x' * 100) + os.utime(old, (0, 0)) + recent = upload_root / 'recent.txt' + recent.write_text('keep') + (upload_root / 'directory').mkdir() + foreign = storage_root / 'scoped/other-workspace/upload/foreign.txt' + foreign.parent.mkdir(parents=True) + foreign.write_text('keep') + os.utime(foreign, (0, 0)) - service = MaintenanceService(ap) - # Create one file and one non-file entry under the scoped upload root. - mock_entry_valid = Mock(spec=Path) - mock_entry_valid.is_file = Mock(return_value=True) - mock_entry_valid.name = 'valid_upload.txt' - mock_stat = Mock() - mock_stat.st_size = 100 - mock_stat.st_mtime = 0 # Very old - mock_entry_valid.stat = Mock(return_value=mock_stat) - mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt')) + result = MaintenanceService(ap)._expired_local_upload_candidates(TEST_CONTEXT, 7) - mock_entry_plugin = Mock(spec=Path) - mock_entry_plugin.is_file = Mock(return_value=False) - mock_entry_plugin.name = 'plugin_config_test.json' - mock_stat2 = Mock() - mock_stat2.st_size = 200 - mock_stat2.st_mtime = 0 - mock_entry_plugin.stat = Mock(return_value=mock_stat2) - - with patch.object(Path, 'exists', return_value=True): - with patch.object(Path, 'rglob') as mock_rglob: - mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin] - result = service._expired_local_upload_candidates(TEST_CONTEXT, 7) - - # Verify - only valid upload included - assert len(result) == 1 - assert result[0]['key'] == 'scoped/valid_upload.txt' + assert result == [ + { + 'key': 'scoped/test-workspace/upload/valid_upload.txt', + 'size_bytes': 100, + 'modified_at': '1970-01-01T00:00:00+00:00', + } + ] + assert prefix.call_args_list == [ + call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload') + ] + assert recent.read_text() == foreign.read_text() == 'keep' def test_expired_local_upload_candidates_includes_path(self): """Includes path when include_paths=True.""" @@ -986,7 +1028,7 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates: ) # Verify - path included - assert 'path' in result[0] + assert result[0]['path'] == '/data/storage/old_file.txt' def test_expired_local_upload_candidates_respects_run_limit(self): ap = SimpleNamespace( diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 5cddde1e4..7ff7ef79f 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio import copy +import sqlalchemy import pytest from unittest.mock import AsyncMock, Mock, MagicMock from types import SimpleNamespace @@ -97,6 +98,22 @@ def _create_mock_result(items: list = None, first_item=None, *, scalar_value=0, return result +def _write_statement(ap, operation): + statements = [ + call.args[0] + for call in ap.persistence_mgr.execute_async.await_args_list + if getattr(call.args[0], f'is_{operation}') + ] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == MCPServer.__tablename__ + if operation != 'insert': + assert statement.whereclause.compare( + sqlalchemy.and_(MCPServer.uuid == 'test-uuid', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid) + ) + return statement + + class TestMCPServiceGetRuntimeInfo: """Tests for get_runtime_info method.""" @@ -118,7 +135,8 @@ class TestMCPServiceGetRuntimeInfo: # Verify assert result is not None - assert result['status'] == 'running' + assert result == {'status': 'running', 'tools': 5} + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'test-server') async def test_get_runtime_info_session_not_exists(self): """Returns None when session not exists.""" @@ -400,6 +418,8 @@ class TestMCPServiceCreateMCPServer: # Execute & Verify - 2 plugins + new server would exceed limit with pytest.raises(ValueError, match='Maximum number of extensions'): await service.create_mcp_server(_CONTEXT, {'name': 'New Server'}) + assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list) + ap.tool_mgr.mcp_tool_loader.get_session.assert_not_called() async def test_create_mcp_server_no_limit(self): """Creates MCP server without limit when max_extensions=-1.""" @@ -427,7 +447,11 @@ class TestMCPServiceCreateMCPServer: # Verify assert server_uuid is not None - assert len(server_uuid) == 36 # UUID format + assert uuid.UUID(server_uuid).version == 4 + values = _write_statement(ap, 'insert').compile().params + assert values['uuid'] == server_uuid + assert values['workspace_uuid'] == _CONTEXT.workspace_uuid + assert values['name'] == 'New Server' async def test_create_mcp_server_duplicate_name_raises(self): """Rejects duplicate MCP server names.""" @@ -485,7 +509,14 @@ class TestMCPServiceCreateMCPServer: await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True}) # Verify - host_mcp_server was called - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'insert').compile().params + assert values['workspace_uuid'] == _CONTEXT.workspace_uuid + assert values['name'] == 'New Server' + assert values['enable'] is True + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': True} + ) async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self): """The Runtime must not observe a server row that can still roll back.""" @@ -536,25 +567,36 @@ class TestMCPServiceCreateMCPServer: ] async def test_create_mcp_server_disabled_no_load(self): - """Does not load server when disabled.""" + """Persists and passes disabled configuration to the runtime loader.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() ap.instance_config = SimpleNamespace() ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}} - ap.tool_mgr = None + ap.tool_mgr = SimpleNamespace( + mcp_tool_loader=SimpleNamespace(host_mcp_server=AsyncMock(), _hosted_mcp_tasks=[]) + ) - mock_result = _create_mock_result([]) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'}) + server = _create_mock_mcp_server(server_uuid='new-uuid', name='New Server', enable=False) + ap.persistence_mgr.execute_async = AsyncMock( + side_effect=[_create_mock_result(), _create_mock_result(), _create_mock_result(first_item=server)] + ) + ap.persistence_mgr.serialize_model = Mock( + return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': False} + ) service = _service(ap) # Execute with enable=False server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False}) - # Verify - no tool_mgr load attempt - assert server_uuid is not None + # Verify the persisted flag and the configuration passed to the loader + assert uuid.UUID(server_uuid).version == 4 + assert _write_statement(ap, 'insert').compile().params['enable'] is False + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': False} + ) class TestMCPServiceGetMCPServerByName: @@ -585,6 +627,12 @@ class TestMCPServiceGetMCPServerByName: # Verify assert result is not None assert result['name'] == 'Found Server' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_(MCPServer.name == 'Found Server', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid) + ) + ap.persistence_mgr.serialize_model.assert_called_once_with(MCPServer, server) + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'Found Server') async def test_get_mcp_server_by_name_not_found(self): """Returns None when MCP server not found.""" @@ -647,7 +695,9 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False}) # Verify - server was removed - ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is False + ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_awaited_once_with(_CONTEXT, 'Old Server') async def test_update_mcp_server_enable_disabled_server(self): """Loads server when enabling previously disabled server.""" @@ -687,7 +737,12 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True}) # Verify - server was loaded - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is True + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True} + ) async def test_update_mcp_server_update_enabled_server(self): """Removes and reloads server when updating enabled server.""" @@ -725,7 +780,13 @@ class TestMCPServiceUpdateMCPServer: # Verify - remove and reload ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Old Server') - ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once() + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is True + assert values.get('extra_args') == {'new': 'args'} + await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks) + ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with( + _CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True} + ) async def test_update_mcp_server_no_tool_mgr(self): """Updates persistence without tool_mgr operations.""" @@ -763,7 +824,8 @@ class TestMCPServiceUpdateMCPServer: await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False}) # Verify - persistence was called - assert ap.persistence_mgr.execute_async.call_count >= 2 + values = _write_statement(ap, 'update').compile().params + assert values['enable'] is False async def test_update_restores_existing_masked_secrets_and_preserves_explicit_changes(self): ap = SimpleNamespace() @@ -884,7 +946,7 @@ class TestMCPServiceDeleteMCPServer: # Verify ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Server to Delete') - ap.persistence_mgr.execute_async.assert_called() + _write_statement(ap, 'delete') async def test_delete_mcp_server_not_in_sessions(self): """Does not attempt remove if server not in sessions.""" @@ -923,7 +985,8 @@ class TestMCPServiceDeleteMCPServer: await service.delete_mcp_server(_CONTEXT, 'test-uuid') # Verify - remove not called (server not in sessions) - ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called() + ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_awaited() + _write_statement(ap, 'delete') async def test_delete_mcp_server_nonexistent_uuid(self): """Delete operation completes even for nonexistent UUID.""" @@ -989,6 +1052,7 @@ class TestMCPServiceTestMCPServer: mock_session = MagicMock() mock_session.status = MCPSessionStatus.ERROR mock_session.start = AsyncMock() + mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3} mock_session.refresh = AsyncMock() ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session) @@ -997,8 +1061,10 @@ class TestMCPServiceTestMCPServer: service = _service(ap) service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'})) - def create_user_task(coroutine, **_kwargs): - coroutine.close() + captured = {} + + def create_user_task(coroutine, **kwargs): + captured.update(coroutine=coroutine, context=kwargs['context']) return SimpleNamespace(id=123) ap.task_mgr.create_user_task = Mock(side_effect=create_user_task) @@ -1007,7 +1073,11 @@ class TestMCPServiceTestMCPServer: task_id = await service.test_mcp_server(_CONTEXT, 'existing-server', {}) # Verify - returns task ID + ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'existing-server') assert task_id == 123 + await captured['coroutine'] + mock_session.start.assert_awaited_once_with() + assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3} @pytest.mark.parametrize('refresh_first', [False, True]) async def test_persisted_test_preserves_failure_details(self, refresh_first): @@ -1064,14 +1134,18 @@ class TestMCPServiceTestMCPServer: mock_session = MagicMock() mock_session.start = AsyncMock() + mock_session.shutdown = AsyncMock() + mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3} ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session) ap.task_mgr = SimpleNamespace() service = _service(ap) - def create_user_task(coroutine, **_kwargs): - coroutine.close() + captured = {} + + def create_user_task(coroutine, **kwargs): + captured.update(coroutine=coroutine, context=kwargs['context']) return SimpleNamespace(id=456) ap.task_mgr.create_user_task = Mock(side_effect=create_user_task) @@ -1080,8 +1154,14 @@ class TestMCPServiceTestMCPServer: task_id = await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'}) # Verify - load_mcp_server called - ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once() + ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_awaited_once_with( + _CONTEXT, {'name': 'New Server', 'workspace_uuid': _CONTEXT.workspace_uuid} + ) assert task_id == 456 + await captured['coroutine'] + mock_session.start.assert_awaited_once_with() + mock_session.shutdown.assert_awaited_once_with() + assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3} async def test_transient_test_preserves_runtime_info_after_connection_failure(self): runtime_info = { diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py index 91afa0916..c9824ca7b 100644 --- a/tests/unit_tests/api/service/test_model_service.py +++ b/tests/unit_tests/api/service/test_model_service.py @@ -14,6 +14,7 @@ Source: src/langbot/pkg/api/http/service/model.py from __future__ import annotations import pytest +import sqlalchemy from unittest.mock import AsyncMock, Mock from types import SimpleNamespace @@ -182,6 +183,42 @@ def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace: ) +def _assert_created_model(ap, model_class, expected): + statements = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_insert] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == model_class.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + kind = {LLMModel: 'llm', EmbeddingModel: 'embedding', RerankModel: 'rerank'}[model_class] + load = getattr(ap.model_mgr, f'load_{kind}_model_with_provider') + load.assert_awaited_once() + context, entity, provider = load.await_args.args + assert context == WORKSPACE_UUID + assert entity.uuid == expected['uuid'] + assert provider is ap.model_mgr.provider_dict[expected['provider_uuid']] + getattr(ap.model_mgr, f'cache_{kind}_model').assert_awaited_once_with(WORKSPACE_UUID, load.return_value) + + +def _assert_provider_query(ap, model_class, provider_uuid): + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_select + assert statement.whereclause.compare( + sqlalchemy.and_(model_class.provider_uuid == provider_uuid, model_class.workspace_uuid == WORKSPACE_UUID) + ) + + +def _assert_model_delete(ap, model_class, model_uuid): + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_delete + assert statement.table.name == model_class.__tablename__ + assert statement.whereclause.compare( + sqlalchemy.and_(model_class.uuid == model_uuid, model_class.workspace_uuid == WORKSPACE_UUID) + ) + + class TestParseProviderApiKeys: """Tests for _parse_provider_api_keys helper function.""" @@ -348,6 +385,8 @@ class TestLLMModelsServiceGetLLMModels: assert len(result) == 1 assert result[0]['name'] == 'Test LLM' assert result[0]['context_length'] == 128000 + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' async def test_get_llm_models_hide_secret_keys(self): """Hides secret API keys when include_secret=False.""" @@ -462,7 +501,9 @@ 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( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = LLMModelsService(ap) @@ -470,7 +511,8 @@ class TestLLMModelsServiceGetLLMModelsByProvider: result = await service.get_llm_models_by_provider(WORKSPACE_UUID, 'target-provider') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['model-1', 'model-2'] + _assert_provider_query(ap, LLMModel, 'target-provider') class TestLLMModelsServiceCreateLLMModel: @@ -507,6 +549,11 @@ class TestLLMModelsServiceCreateLLMModel: # Verify assert model_uuid is not None assert len(model_uuid) == 36 # UUID format + _assert_created_model( + ap, + LLMModel, + {'uuid': model_uuid, 'workspace_uuid': WORKSPACE_UUID, 'name': 'New LLM', 'provider_uuid': 'provider-uuid'}, + ) async def test_create_llm_model_preserve_uuid(self): """Creates LLM model preserving provided UUID.""" @@ -540,6 +587,16 @@ class TestLLMModelsServiceCreateLLMModel: # Verify assert model_uuid == 'preserved-uuid' + _assert_created_model( + ap, + LLMModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'Preserved UUID Model', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_llm_model_persists_context_length_as_column(self): """Creates LLM model with context_length outside extra_args.""" @@ -574,6 +631,17 @@ class TestLLMModelsServiceCreateLLMModel: assert runtime_entity.context_length == 128000 assert runtime_entity.extra_args == {'temperature': 0.2} assert 'context_length' not in runtime_entity.extra_args + _assert_created_model( + ap, + LLMModel, + { + 'uuid': 'model-with-context', + 'workspace_uuid': WORKSPACE_UUID, + 'provider_uuid': 'provider-uuid', + 'context_length': 128000, + 'extra_args': {'temperature': 0.2}, + }, + ) async def test_create_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found in runtime.""" @@ -639,7 +707,9 @@ class TestLLMModelsServiceCreateLLMModel: ) # Verify - provider_service was called and UUID generated - ap.provider_service.find_or_create_provider.assert_called_once() + ap.provider_service.find_or_create_provider.assert_awaited_once_with( + WORKSPACE_UUID, requester='openai', base_url='https://api.openai.com', api_keys=['key'] + ) assert result_uuid is not None async def test_create_llm_model_validates_explicit_reasoning_level(self): @@ -738,6 +808,11 @@ class TestLLMModelsServiceUpdateLLMModel: # Verify - remove and load called ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert 'uuid' not in statement.compile().params + assert statement.compile().params.get('name') == 'Updated Name' async def test_update_llm_model_provider_not_found_raises_error(self): """Raises Exception when provider not found after update.""" @@ -795,6 +870,11 @@ class TestLLMModelsServiceUpdateLLMModel: assert runtime_entity.context_length == 64000 assert runtime_entity.extra_args == {'temperature': 0.4} assert 'context_length' not in runtime_entity.extra_args + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert statement.compile().params.get('context_length') == 64000 + assert statement.compile().params.get('extra_args') == {'temperature': 0.4} class TestLLMModelsServiceDeleteLLMModel: @@ -817,7 +897,8 @@ class TestLLMModelsServiceDeleteLLMModel: # Verify ap.persistence_mgr.execute_async.assert_called_once() - ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'delete-uuid') + ap.model_mgr.remove_llm_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-uuid') + _assert_model_delete(ap, LLMModel, 'delete-uuid') class TestEmbeddingModelsServiceGetEmbeddingModels: @@ -880,7 +961,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels: ) # Verify - assert len(result) == 1 + assert [(item['uuid'], item['name']) for item in result] == [('embedding-uuid', 'Test Embedding')] + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' class TestEmbeddingModelsServiceGetEmbeddingModel: @@ -907,11 +990,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel: ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'found-embedding', - 'name': 'Found Embedding', - 'provider': {'uuid': 'provider-uuid'}, - } + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} ) service = EmbeddingModelsService(ap) @@ -920,7 +999,11 @@ class TestEmbeddingModelsServiceGetEmbeddingModel: result = await service.get_embedding_model(WORKSPACE_UUID, 'found-embedding') # Verify - assert result is not None + assert result == { + 'uuid': 'found-embedding', + 'name': 'Test Embedding', + 'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'}, + } async def test_get_embedding_model_not_found(self): """Returns None when model not found.""" @@ -971,6 +1054,16 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel: # Verify assert model_uuid is not None assert len(model_uuid) == 36 + _assert_created_model( + ap, + EmbeddingModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Embedding', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_embedding_model_provider_not_found_raises(self): """Raises Exception when provider not found.""" @@ -1016,7 +1109,8 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel: await service.delete_embedding_model(WORKSPACE_UUID, 'delete-embedding-uuid') # Verify - ap.model_mgr.remove_embedding_model.assert_called_once() + ap.model_mgr.remove_embedding_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-embedding-uuid') + _assert_model_delete(ap, EmbeddingModel, 'delete-embedding-uuid') class TestRerankModelsServiceGetRerankModels: @@ -1078,7 +1172,9 @@ class TestRerankModelsServiceGetRerankModels: ) # Verify - assert len(result) == 1 + assert [(item['uuid'], item['name']) for item in result] == [('rerank-uuid', 'Test Rerank')] + assert result[0].get('provider', {}).get('uuid') == 'provider-uuid' + assert result[0].get('provider', {}).get('name') == 'Test Provider' class TestRerankModelsServiceGetRerankModel: @@ -1105,11 +1201,7 @@ class TestRerankModelsServiceGetRerankModel: ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) ap.persistence_mgr.serialize_model = Mock( - return_value={ - 'uuid': 'found-rerank', - 'name': 'Found Rerank', - 'provider': {'uuid': 'provider-uuid'}, - } + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} ) service = RerankModelsService(ap) @@ -1118,7 +1210,11 @@ class TestRerankModelsServiceGetRerankModel: result = await service.get_rerank_model(WORKSPACE_UUID, 'found-rerank') # Verify - assert result is not None + assert result == { + 'uuid': 'found-rerank', + 'name': 'Test Rerank', + 'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'}, + } async def test_get_rerank_model_not_found(self): """Returns None when model not found.""" @@ -1168,6 +1264,16 @@ class TestRerankModelsServiceCreateRerankModel: # Verify assert model_uuid is not None + _assert_created_model( + ap, + RerankModel, + { + 'uuid': model_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Rerank', + 'provider_uuid': 'provider-uuid', + }, + ) async def test_create_rerank_model_provider_not_found_raises(self): """Raises Exception when provider not found.""" @@ -1213,7 +1319,8 @@ class TestRerankModelsServiceDeleteRerankModel: await service.delete_rerank_model(WORKSPACE_UUID, 'delete-rerank-uuid') # Verify - ap.model_mgr.remove_rerank_model.assert_called_once() + ap.model_mgr.remove_rerank_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-rerank-uuid') + _assert_model_delete(ap, RerankModel, 'delete-rerank-uuid') class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: @@ -1230,7 +1337,9 @@ 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( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = EmbeddingModelsService(ap) @@ -1238,7 +1347,8 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider: result = await service.get_embedding_models_by_provider(WORKSPACE_UUID, 'provider-uuid') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['emb-1', 'emb-2'] + _assert_provider_query(ap, EmbeddingModel, 'provider-uuid') class TestRerankModelsServiceGetRerankModelsByProvider: @@ -1255,7 +1365,9 @@ 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( + side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name} + ) service = RerankModelsService(ap) @@ -1263,7 +1375,8 @@ class TestRerankModelsServiceGetRerankModelsByProvider: result = await service.get_rerank_models_by_provider(WORKSPACE_UUID, 'provider-uuid') # Verify - assert len(result) == 2 + assert [item['uuid'] for item in result] == ['rerank-1', 'rerank-2'] + _assert_provider_query(ap, RerankModel, 'provider-uuid') class TestValidateProviderSupports: diff --git a/tests/unit_tests/api/service/test_monitoring_executions.py b/tests/unit_tests/api/service/test_monitoring_executions.py new file mode 100644 index 000000000..39587f245 --- /dev/null +++ b/tests/unit_tests/api/service/test_monitoring_executions.py @@ -0,0 +1,1150 @@ +"""Execution-view tests for the monitoring service. + +Covers the unified agent-run + pipeline-query execution list, its rollup +summary, and single-execution traces. +""" + +from __future__ import annotations + +import datetime +from types import SimpleNamespace + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.entity.persistence import agent_interaction as persistence_interaction +from langbot.pkg.entity.persistence import agent_run as persistence_agent_run +from langbot.pkg.entity.persistence import monitoring as persistence_monitoring +from langbot.pkg.entity.persistence import workspace as persistence_workspace # noqa: F401 +from langbot.pkg.entity.persistence.base import Base + +WORKSPACE = 'workspace-1' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('has_log', [False, True]) +async def test_event_properties_survive_detail_snapshot_fallback(engine, service, has_log): + import json + from langbot.pkg.entity.persistence.event_log import EventLog + + data = {'member': {'id': 'u1', 'active': False}, 'count': 0, 'custom': {'reason': 'joined'}} + async with AsyncSession(engine) as session: + session.add( + persistence_agent_run.AgentRun( + run_id='custom-run', + event_id='custom-event', + workspace_id=WORKSPACE, + runner_id='runner', + status='completed', + created_at=_dt(10), + metadata_json=json.dumps( + { + 'event_type': 'group.member_joined', + 'input_event': data, + 'input': {'text': '', 'contents': [], 'attachments': []}, + } + ), + ) + ) + if has_log: + session.add( + EventLog( + event_id='custom-event', + event_type='group.member_joined', + source='webui', + workspace_id=WORKSPACE, + run_id='custom-run', + input_json='{"text":"","contents":[]}', + created_at=_dt(10), + ) + ) + session.add( + EventLog( + event_id='later-event', + event_type='message.received', + source='webui', + workspace_id=WORKSPACE, + run_id='custom-run', + input_json='{"text":"later"}', + created_at=_dt(10, 1), + ) + ) + await session.commit() + detail = await service.get_execution_detail(WORKSPACE, 'agent', 'custom-run') + items = detail['pages']['inputs']['items'] + assert items[0]['content']['event']['member'] == data['member'] + assert items[0]['content']['event']['count'] == 0 + assert items[0]['content']['event']['custom'] == data['custom'] + if has_log: + assert items[1]['content'] == {'text': 'later'} + + +def test_event_content_preserves_conflicting_custom_input_fields(): + from langbot.pkg.api.http.service.monitoring_execution_details import event_content + + assert event_content({'text': 'normalized'}, {'text': {'custom': False}}) == { + 'text': 'event', + 'event': {'text': {'custom': False}}, + 'input': {'text': 'normalized'}, + } + + +class FakePersistenceManager: + """Runs statements against a real in-memory SQLite engine.""" + + def __init__(self, engine): + self._engine = engine + self._factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + def get_db_engine(self): + return self._engine + + def tenant_uow(self, workspace_uuid): + from langbot.pkg.persistence.tenant_uow import TenantUnitOfWork + + return TenantUnitOfWork(self._engine, workspace_uuid) + + async def execute_async(self, statement): + # Match PersistenceManager's connection-level result shape. + async with self._engine.connect() as connection: + return await connection.execute(statement) + + @staticmethod + def serialize_model(model, data): + out = {} + for column in model.__table__.columns: + value = getattr(data, column.name) + out[column.name] = value.isoformat() if isinstance(value, datetime.datetime) else value + return out + + +@pytest.fixture +async def engine(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "monitoring_executions.db"}') + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +def service(engine): + ap = SimpleNamespace( + persistence_mgr=FakePersistenceManager(engine), + instance_config=None, + ) + return MonitoringService(ap) + + +def _dt(hour: int, minute: int = 0, second: int = 0) -> datetime.datetime: + return datetime.datetime(2026, 1, 1, hour, minute, second) + + +async def _seed(engine) -> None: + factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + session.add_all( + [ + persistence_agent_run.AgentRun( + run_id='run-done', + event_id='evt-1', + agent_id='agent-1', + binding_id='agent_agent-1_default', + runner_id='runner-1', + workspace_id=WORKSPACE, + bot_id='bot-1', + status='completed', + created_at=_dt(10), + started_at=_dt(10, 0, 1), + finished_at=_dt(10, 0, 3), + usage_json='{"total_tokens": 42}', + ), + persistence_agent_run.AgentRun( + run_id='run-failed', + binding_id='agent_agent-1_default', + runner_id='runner-1', + workspace_id=WORKSPACE, + bot_id='bot-1', + status='failed', + created_at=_dt(10, 5), + ), + persistence_agent_run.AgentRun( + run_id='run-debug', + event_id='debug:agent-1:e1', + agent_id='agent-1', + binding_id='debug:agent-1:runner-1', + runner_id='runner-1', + workspace_id=WORKSPACE, + bot_id='bot-1', + status='completed', + created_at=_dt(10, 6), + started_at=_dt(10, 6, 0), + finished_at=_dt(10, 6, 1), + ), + persistence_agent_run.AgentRunEvent( + run_id='run-done', + sequence=1, + type='run.completed', + data_json='{"message": {"role": "assistant", "content": "hi"}}', + ), + persistence_interaction.AgentInteraction( + interaction_id='int-1', + run_id='run-done', + binding_id='agent_agent-1_default', + runner_id='runner-1', + processor_type='agent', + processor_id='agent-1', + workspace_id=WORKSPACE, + status='pending', + request_json='{}', + callback_token_hash='hash-1', + ), + ] + ) + for message in [ + dict( + id='msg-user', + timestamp=_dt(10, 2), + session_id='session-1', + message_content='{"chain": [{"type": "Plain", "text": "hello there"}]}', + status='success', + role='user', + ), + dict( + id='msg-assistant', + timestamp=_dt(10, 3), + session_id='session-1', + message_content='{"chain": [{"type": "Plain", "text": "reply"}]}', + status='success', + role='assistant', + ), + dict( + id='msg-discarded', + timestamp=_dt(10, 4), + session_id='session-2', + message_content='{"chain": [{"type": "Plain", "text": "ignored"}]}', + status='discarded', + role='user', + ), + ]: + session.add( + persistence_monitoring.MonitoringMessage( + workspace_uuid=WORKSPACE, + bot_id='bot-1', + bot_name='Bot One', + pipeline_id='pipeline-1', + pipeline_name='Pipeline One', + level='info', + **message, + ) + ) + session.add_all( + [ + persistence_monitoring.MonitoringLLMCall( + id='llm-1', + workspace_uuid=WORKSPACE, + timestamp=_dt(10, 2, 30), + model_name='gpt', + input_tokens=10, + output_tokens=20, + total_tokens=30, + duration=100, + status='success', + bot_id='bot-1', + bot_name='Bot One', + pipeline_id='pipeline-1', + pipeline_name='Pipeline One', + session_id='session-1', + message_id='msg-user', + ), + persistence_monitoring.MonitoringLLMCall( + id='llm-2', + workspace_uuid=WORKSPACE, + timestamp=_dt(10, 4, 30), + model_name='gpt', + input_tokens=0, + output_tokens=0, + total_tokens=0, + duration=50, + status='success', + bot_id='bot-1', + bot_name='Bot One', + pipeline_id='pipeline-1', + pipeline_name='Pipeline One', + session_id='session-2', + message_id='msg-discarded', + ), + ] + ) + session.add( + persistence_monitoring.MonitoringSession( + workspace_uuid=WORKSPACE, + bot_id='bot-1', + session_id='session-1', + bot_name='Bot One', + pipeline_id='pipeline-1', + pipeline_name='Pipeline One', + message_count=2, + start_time=_dt(10, 2), + last_activity=_dt(10, 5), + is_active=True, + ) + ) + await session.commit() + + +@pytest.mark.asyncio +async def test_executions_merge_and_order_both_sources(engine, service): + await _seed(engine) + + result = await service.get_executions(WORKSPACE, limit=50) + + assert result['total'] == 5 + ids = [(item['source'], item['id']) for item in result['items']] + # Assistant replies are outputs, not executions. + assert ('pipeline', 'msg-assistant') not in ids + # Newest first across both object types. + assert ids == [ + ('agent', 'run-debug'), + ('agent', 'run-failed'), + ('pipeline', 'msg-discarded'), + ('pipeline', 'msg-user'), + ('agent', 'run-done'), + ] + + by_id = {item['id']: item for item in result['items']} + assert result['summary']['executions']['total'] == 5 + assert result['summary']['executions']['denominator'] == 4 + assert result['summary']['tokens']['calls'] == 2 + assert result['summary']['tokens']['calls_with_usage'] == 1 + assert by_id['run-done']['status_group'] == 'completed' + assert by_id['run-failed']['status_group'] == 'failed' + assert by_id['run-debug']['debug'] is True + assert by_id['run-done']['debug'] is False + assert by_id['run-done']['usage'] == {'total_tokens': 42} + assert by_id['run-done']['duration_ms'] == 2000 + assert by_id['msg-user']['status_group'] == 'completed' + assert by_id['msg-discarded']['status_group'] == 'ignored' + assert by_id['msg-user']['title'] == 'hello there' + assert by_id['msg-user']['target_id'] == 'pipeline-1' + + +@pytest.mark.asyncio +async def test_executions_source_status_and_paging(engine, service): + await _seed(engine) + + only_agents = await service.get_executions(WORKSPACE, source='agent') + assert {item['source'] for item in only_agents['items']} == {'agent'} + assert only_agents['total'] == 3 + + pipeline_only = await service.get_executions(WORKSPACE, source='pipeline') + assert {item['source'] for item in pipeline_only['items']} == {'pipeline'} + assert pipeline_only['total'] == 2 + + # A single-object target selection narrows the union to that source. + by_pipeline = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1']) + assert {item['source'] for item in by_pipeline['items']} == {'pipeline'} + + failed = await service.get_executions(WORKSPACE, statuses=['failed']) + assert [item['id'] for item in failed['items']] == ['run-failed'] + + # `ignored` only exists for pipeline queries: it must not widen the agent + # filter back to "every status". + ignored = await service.get_executions(WORKSPACE, statuses=['ignored']) + assert [item['id'] for item in ignored['items']] == ['msg-discarded'] + + debug_only = await service.get_executions(WORKSPACE, mode='debug') + assert [item['id'] for item in debug_only['items']] == ['run-debug'] + + first_page = await service.get_executions(WORKSPACE, limit=2) + assert len(first_page['items']) == 2 + assert first_page['has_more'] is True + second_page = await service.get_executions(WORKSPACE, limit=2, offset=2) + assert first_page['items'][0]['id'] != second_page['items'][0]['id'] + + +@pytest.mark.asyncio +async def test_execution_summary_uses_explicit_denominator(engine, service): + await _seed(engine) + + summary = (await service.get_executions(WORKSPACE))['summary'] + executions = summary['executions'] + + # completed = run-done + run-debug + msg-user; failed = run-failed. + assert executions['by_source'] == {'agent': 3, 'pipeline': 2} + assert executions['completed'] == 3 + assert executions['failed'] == 1 + assert executions['ignored'] == 1 + assert executions['denominator'] == 4 + assert executions['success_rate'] == 75.0 + assert executions['debug'] == 1 + assert executions['real'] == 4 + assert executions['waiting'] == 1 + assert executions['p50_duration_ms'] == 1000 + assert executions['p95_duration_ms'] == 2000 + # Only run-done and run-debug are completed runs with a start and a finish. + assert executions['duration_sample'] == 2 + + # Token coverage is explicit: llm-2 succeeded without usage. + assert summary['tokens'] == {'total_tokens': 30, 'calls': 2, 'calls_with_usage': 1} + + +@pytest.mark.asyncio +async def test_overview_metrics_keep_message_rollup(engine, service): + await _seed(engine) + + overview = await service.get_overview_metrics(WORKSPACE) + + assert overview['total_messages'] == 3 + assert overview['llm_calls'] == 2 + assert overview['model_calls'] == 2 + assert overview['active_sessions'] == 1 + + +@pytest.mark.asyncio +async def test_execution_detail_for_agent_and_pipeline(engine, service): + await _seed(engine) + + agent_detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done') + assert agent_detail['run']['run_id'] == 'run-done' + assert agent_detail['events'][0]['type'] == 'run.completed' + + pipeline_detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user') + assert pipeline_detail['message']['id'] == 'msg-user' + assert [call['id'] for call in pipeline_detail['llm_calls']] == ['llm-1'] + assert pipeline_detail['tool_calls'] == [] + + with pytest.raises(ValueError): + await service.get_execution_detail(WORKSPACE, 'agent', 'missing-run') + + with pytest.raises(ValueError): + await service.get_execution_detail(WORKSPACE, 'pipeline', 'missing-message') + + +@pytest.mark.asyncio +async def test_detail_overview_does_not_query_collapsed_sections(engine, service, monkeypatch): + await _seed(engine) + page = service._execution_page + converters = [] + + async def capture(workspace, statement, convert, offset, limit): + converters.append(convert.__name__) + return await page(workspace, statement, convert, offset, limit) + + async def reject_history(*args, **kwargs): + pytest.fail('Collapsed conversation history must not be loaded') + + monkeypatch.setattr(service, '_execution_page', capture) + monkeypatch.setattr(service, '_conversation_message_page', reject_history) + detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user', section='overview') + + assert set(detail['pages']) == {'inputs', 'outputs', 'deliveries', 'errors'} + assert detail['pages']['inputs']['items'][0]['id'] == 'msg-user' + assert len(converters) == 4 + assert 'events' not in detail + + +@pytest.mark.asyncio +async def test_conversation_pages_identities_from_covering_index_before_payloads(engine, service): + import sqlalchemy as sa + + await _seed(engine) + message = persistence_monitoring.MonitoringMessage + payload = 'historical inline media ' * 4096 + async with engine.begin() as connection: + await connection.execute( + sa.insert(message), + [ + dict( + id=f'history-{index:02d}', + workspace_uuid=workspace, + bot_id=bot, + bot_name='Bot', + pipeline_id='pipeline', + pipeline_name='Pipeline', + session_id='session-1', + timestamp=_dt(12), + role='user', + message_content=payload, + variables=payload, + status='success', + level='info', + ) + for index, (workspace, bot) in enumerate( + [(WORKSPACE, 'bot-1')] * 5 + [(WORKSPACE, 'other-bot'), ('other-workspace', 'bot-1')] + ) + ], + ) + statements = [] + + def capture(connection, cursor, statement, parameters, context, executemany): + if statement.startswith('SELECT'): + statements.append((statement, parameters)) + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture) + try: + first = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user', section='conversation', limit=2) + second = await service.get_execution_detail( + WORKSPACE, 'pipeline', 'msg-user', section='conversation', limit=2, offset=2 + ) + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture) + + page = first['pages']['conversation'] + assert [row['id'] for row in page['items']] == ['history-04', 'history-03'] + assert page['has_more'] and page['next_offset'] == 2 + assert all(row['content'] == payload for row in page['items']) + assert [row['id'] for row in second['pages']['conversation']['items']] == ['history-02', 'history-01'] + identities = [(sql, params) for sql, params in statements if sql.startswith('SELECT monitoring_messages.id \n')] + assert len(identities) == 2 + async with engine.connect() as connection: + for sql, params in identities: + assert 'message_content' not in sql and 'variables' not in sql + plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql, params)).all() + assert any('COVERING INDEX ix_monitoring_messages_conversation_page' in row[3] for row in plan), plan + assert not any('TEMP B-TREE' in row[3] for row in plan), plan + payload_queries = [(sql, params) for sql, params in statements if 'JOIN monitoring_messages ON' in sql] + assert len(payload_queries) == 2 + assert {value for value in payload_queries[0][1] if str(value).startswith('history-')} == { + 'history-04', + 'history-03', + } + for sql, params in payload_queries: + plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql, params)).all() + assert any('sqlite_autoindex_monitoring_messages_1 (id=?)' in row[3] for row in plan), plan + + +@pytest.mark.asyncio +async def test_explicit_deliveries_context_and_tenant_isolation(engine, service): + import sqlalchemy as sa + + await _seed(engine) + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.id == 'msg-assistant') + .values(parent_message_id='msg-user') + ) + await session.commit() + detail = await service.get_execution_detail(WORKSPACE, 'auto', 'msg-assistant') + assert detail['row']['id'] == 'msg-user' + assert [m['id'] for m in detail['pages']['deliveries']['items']] == ['msg-assistant'] + assert detail['pages']['inputs']['items'][0]['id'] == 'msg-user' + assert detail['pages']['conversation']['items'] + with pytest.raises(ValueError, match='not found'): + await service.get_execution_detail('another-workspace', 'auto', 'msg-assistant') + + +@pytest.mark.asyncio +async def test_legacy_replies_are_context_not_guessed_deliveries(engine, service): + await _seed(engine) + detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user') + assert detail['legacy_context'] is True + assert detail['pages']['deliveries']['items'] == [] + assert 'msg-assistant' in [m['id'] for m in detail['pages']['conversation']['items']] + + +@pytest.mark.asyncio +async def test_fanout_steering_orphans_and_paged_trace(engine, service): + import json + import sqlalchemy as sa + from langbot.pkg.entity.persistence.event_log import EventLog + from langbot.pkg.entity.persistence.transcript import Transcript + + await _seed(engine) + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_agent_run.AgentRun) + .where(persistence_agent_run.AgentRun.run_id == 'run-done') + .values( + conversation_id='shared', + thread_id='thread-1', + metadata_json=json.dumps({'processor_type': 'event_processor'}), + ) + ) + session.add_all( + [ + EventLog( + event_id='evt-1', + event_type='message.received', + source='platform', + workspace_id=WORKSPACE, + bot_id='bot-1', + input_json='{"text":"original input"}', + actor_name='Alice', + created_at=_dt(10), + ), + EventLog( + event_id='steer', + event_type='message.received', + source='platform', + workspace_id=WORKSPACE, + bot_id='bot-1', + run_id='run-done', + input_json='{"text":"additional input"}', + created_at=_dt(10, 1), + ), + EventLog( + event_id='unrouted', + event_type='group.member.joined', + source='platform', + workspace_id=WORKSPACE, + bot_id='bot-1', + metadata_json='{"status":"ignored"}', + created_at=_dt(10, 7), + ), + persistence_agent_run.AgentRun( + run_id='sibling', + event_id='evt-1', + workspace_id=WORKSPACE, + bot_id='bot-1', + runner_id='plugin', + status='completed', + created_at=_dt(10), + ), + Transcript( + transcript_id='output', + event_id='generated', + conversation_id='shared', + thread_id='thread-1', + workspace_id=WORKSPACE, + bot_id='bot-1', + role='assistant', + content='generated only', + run_id='run-done', + seq=1, + ), + Transcript( + transcript_id='other-thread', + event_id='other', + conversation_id='shared', + thread_id='thread-2', + workspace_id=WORKSPACE, + bot_id='bot-1', + role='user', + content='private other thread', + seq=2, + ), + ] + ) + session.add_all( + [ + persistence_agent_run.AgentRunEvent(run_id='run-done', sequence=i, type='message.delta', data_json='{}') + for i in range(2, 206) + ] + ) + await session.commit() + result = await service.get_executions(WORKSPACE) + assert 'unrouted' in [r['id'] for r in result['items']] + assert 'steer' not in [r['id'] for r in result['items']] + ignored = await service.get_executions(WORKSPACE, statuses=['ignored']) + assert 'unrouted' in [r['id'] for r in ignored['items']] + detail = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done') + assert detail['row']['target_kind'] == 'event_processor' + assert detail['row']['user_name'] == 'Alice' + assert [i['id'] for i in detail['pages']['inputs']['items']] == ['evt-1', 'steer'] + assert detail['pages']['outputs']['items'][0]['content'] == 'generated only' + assert [i['id'] for i in detail['pages']['related']['items']] == ['sibling'] + assert 'other-thread' not in [i['id'] for i in detail['pages']['conversation']['items']] + assert len(detail['pages']['events']['items']) == 100 + assert detail['pages']['events']['has_more'] + final = await service.get_execution_detail(WORKSPACE, 'agent', 'run-done', section='events', offset=200) + assert len(final['pages']['events']['items']) == 5 + assert not final['pages']['events']['has_more'] + orphan = await service.get_execution_detail(WORKSPACE, 'event', 'unrouted') + assert orphan['pages']['deliveries']['items'] == [] + assert orphan['pages']['conversation']['items'] == [] + + +@pytest.mark.asyncio +async def test_pipeline_runner_is_not_counted_twice(engine, service): + import sqlalchemy as sa + + await _seed(engine) + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.id == 'msg-user') + .values(run_id='run-done') + ) + await session.commit() + result = await service.get_executions(WORKSPACE) + assert result['total'] == 4 + assert result['summary']['executions']['total'] == 4 + assert 'run-done' not in [r['id'] for r in result['items']] + detail = await service.get_execution_detail(WORKSPACE, 'pipeline', 'msg-user') + assert detail['row']['duration_ms'] == 2000 + assert detail['events'][0]['type'] == 'run.completed' + + +@pytest.mark.asyncio +async def test_event_log_fanout_is_idempotent_and_cannot_cross_tenants(engine): + from langbot.pkg.agent.runner.event_log_store import EventLogStore + + store = EventLogStore(engine) + await store.append_event('event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot') + await store.append_event( + 'event-id', + 'message.received', + 'platform', + workspace_id=WORKSPACE, + bot_id='bot', + run_id='first-run', + input_json={'text': 'input', 'attachments': []}, + ) + await store.append_event( + 'event-id', 'message.received', 'platform', workspace_id=WORKSPACE, bot_id='bot', run_id='second-run' + ) + event = await store.get_event('event-id') + assert event['run_id'] == 'first-run' + assert event['input_json']['text'] == 'input' + with pytest.raises(Exception): + await store.append_event('event-id', 'message.received', 'platform', workspace_id='other', bot_id='bot') + + +@pytest.mark.asyncio +async def test_processor_filter_scopes_each_kind_and_legacy_calls(engine, service): + import json + + await _seed(engine) + async with AsyncSession(engine) as session: + session.add( + persistence_agent_run.AgentRun( + run_id='event-run', + workspace_id=WORKSPACE, + runner_id='event-runner', + status='completed', + created_at=_dt(11), + metadata_json=json.dumps({'processor_type': 'event_processor', 'processor_id': 'event-processor-1'}), + ) + ) + session.add( + persistence_monitoring.MonitoringLLMCall( + id='event-call', + bot_id='bot-1', + bot_name='Bot One', + pipeline_name='Event Processor', + session_id='session-event', + input_tokens=6, + output_tokens=6, + duration=1, + timestamp=_dt(11), + workspace_uuid=WORKSPACE, + model_name='model', + message_id='event-run', + pipeline_id='', + status='success', + total_tokens=12, + ) + ) + await session.commit() + for processor, expected in [ + ('agent-1', {'run-done', 'run-debug'}), + ('pipeline-1', {'msg-user', 'msg-discarded'}), + ('event-processor-1', {'event-run'}), + ('missing', set()), + ]: + result = await service.get_executions(WORKSPACE, pipeline_ids=[processor]) + assert {row['id'] for row in result['items']} == expected + assert result['total'] == result['summary']['executions']['total'] == len(expected) + calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['event-processor-1']) + assert total == 1 + assert [row['id'] for row in calls] == ['event-call'] + other = await service.get_executions('another-workspace', pipeline_ids=['event-processor-1']) + assert other['total'] == 0 + + +@pytest.mark.asyncio +async def test_execution_filters_also_scope_cards_calls_and_traffic(engine, service): + import sqlalchemy as sa + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + await _seed(engine) + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_monitoring.MonitoringLLMCall) + .where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1') + .values(message_id='run-failed', pipeline_id='') + ) + await session.commit() + failed = await service.get_executions(WORKSPACE, statuses=['failed']) + assert failed['total'] == failed['summary']['executions']['total'] == 1 + assert failed['summary']['executions']['waiting'] == 0 + calls, total = await service.get_llm_calls(WORKSPACE, execution_statuses=['failed']) + assert total == 1 and calls[0]['id'] == 'llm-1' + # A successful model call may belong to a failed execution. + assert calls[0]['status'] == 'success' + overview = await service.get_overview_metrics(WORKSPACE, execution_statuses=['failed']) + assert overview['llm_calls'] == 1 and overview['total_messages'] == 0 + traffic = await get_traffic_series(service.ap, WORKSPACE, execution_statuses=['failed']) + assert sum(p['llm_calls'] for p in traffic['points']) == 1 + assert sum(p['messages'] for p in traffic['points']) == 0 + + debug = await service.get_executions(WORKSPACE, mode='debug') + assert debug['total'] == debug['summary']['executions']['total'] == 1 + real = await service.get_executions(WORKSPACE, mode='real') + assert real['total'] == real['summary']['executions']['total'] == 4 + traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug') + assert sum(p['messages'] for p in traffic['points']) == 0 + + +@pytest.mark.asyncio +async def test_pipeline_debug_is_consistent_across_rows_rollups_and_telemetry(engine, service): + import sqlalchemy as sa + from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series + + await _seed(engine) + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.id == 'msg-user') + .values(bot_id='websocket-proxy-bot') + ) + await session.execute( + sa.update(persistence_monitoring.MonitoringLLMCall) + .where(persistence_monitoring.MonitoringLLMCall.id == 'llm-1') + .values(message_id='msg-user', pipeline_id='pipeline-1') + ) + await session.execute( + sa.update(persistence_agent_run.AgentRun) + .where(persistence_agent_run.AgentRun.run_id == 'run-done') + .values(bot_id='websocket-proxy-bot') + ) + await session.commit() + debug = await service.get_executions(WORKSPACE, mode='debug') + ids = {r['id'] for r in debug['items']} + assert ids == {'msg-user', 'run-done', 'run-debug'} + assert all(r['debug'] for r in debug['items']) + assert debug['summary']['executions']['total'] == 3 + assert debug['summary']['executions']['debug'] == 3 + real = await service.get_executions(WORKSPACE, mode='real') + assert not ids.intersection(r['id'] for r in real['items']) + assert real['summary']['executions']['debug'] == 0 + filtered = await service.get_executions(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug') + assert [r['id'] for r in filtered['items']] == ['msg-user'] + calls, total = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='debug') + assert total == 1 and calls[0]['id'] == 'llm-1' + real_rows, real_calls = await service.get_llm_calls(WORKSPACE, pipeline_ids=['pipeline-1'], mode='real') + assert real_calls == 1 and real_rows[0]['id'] == 'llm-2' + traffic = await get_traffic_series(service.ap, WORKSPACE, mode='debug', pipeline_ids=['pipeline-1']) + assert sum(p['llm_calls'] for p in traffic['points']) == 1 + + +@pytest.mark.asyncio +async def test_inflight_excludes_unowned_and_expired_messages_without_rewriting_history(engine, service): + import sqlalchemy as sa + + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + Message = persistence_monitoring.MonitoringMessage + async with AsyncSession(engine) as session: + for message_id, owner, expiry, timestamp in [ + ('legacy', None, None, _dt(1)), + ('recent-unowned', None, now + datetime.timedelta(minutes=1), now), + ('expired', 'stopped-host', now - datetime.timedelta(seconds=1), _dt(1)), + ('long-running', 'live-host', now + datetime.timedelta(minutes=1), _dt(1)), + ]: + session.add( + Message( + id=message_id, + workspace_uuid=WORKSPACE, + timestamp=timestamp, + bot_id='bot', + bot_name='Bot', + pipeline_id='pipeline', + pipeline_name='Pipeline', + session_id='session', + message_content='historical message', + status='pending', + level='info', + execution_owner_id=owner, + execution_lease_expires_at=expiry, + ) + ) + session.add( + persistence_agent_run.AgentRun( + run_id='remote-queue', + workspace_id=WORKSPACE, + runner_id='runner', + status='queued', + queue_name='remote', + created_at=_dt(1), + ) + ) + await session.commit() + # A previous connection must not reintroduce unowned pending rows either. + snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'legacy'}]) + assert snapshot['active_total'] == 2 + assert {row['id'] for row in snapshot['items']} == {'long-running', 'remote-queue'} + async with AsyncSession(engine) as session: + statuses = (await session.execute(sa.select(Message.status))).scalars().all() + assert statuses == ['pending'] * 4 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['all', 'pipeline']) +@pytest.mark.parametrize('statuses', [['running'], ['running', 'queued']]) +async def test_active_inflight_count_and_page_use_the_indexed_lease_range(engine, service, source, statuses): + import sqlalchemy as sa + + await _seed(engine) + async with engine.begin() as connection: + await connection.execute( + sa.update(persistence_monitoring.MonitoringMessage) + .where(persistence_monitoring.MonitoringMessage.id == 'msg-user') + .values(status='pending', message_content='historical payload ' * 8192) + ) + statements = [] + + def capture(connection, cursor, statement, parameters, context, executemany): + if statement.startswith('SELECT '): + statements.append((statement, parameters)) + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture) + try: + result = await service.get_executions( + WORKSPACE, statuses=statuses, source=source, include_summary=False, for_inflight=True + ) + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture) + + assert result == {'items': [], 'total': 0, 'has_more': False} + assert len(statements) == 2 # Both COUNT and the sorted page must skip historical payloads. + async with engine.connect() as connection: + for statement, parameters in statements: + plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all() + assert any( + 'ix_monitoring_messages_workspace_status_lease' in row[3] and 'execution_lease_expires_at>?' in row[3] + for row in plan + ), plan + + +@pytest.mark.asyncio +async def test_empty_inflight_snapshot_skips_linked_history_and_clears_tracking(engine, service, monkeypatch): + import sqlalchemy as sa + from langbot.pkg.utils.inflight import inflight_hub + + await _seed(engine) + monkeypatch.setattr(inflight_hub, 'run_workspaces', {'finished-run': WORKSPACE, 'other-run': 'other-workspace'}) + statements = [] + + def capture(connection, cursor, statement, parameters, context, executemany): + statements.append(statement) + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture) + try: + snapshot = await service.get_inflight_snapshot(WORKSPACE) + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture) + + assert snapshot == {'items': [], 'active_total': 0, 'truncated': False} + assert inflight_hub.run_workspaces == {'other-run': 'other-workspace'} + assert not any('JOIN monitoring_messages ON' in query or 'FROM agent_run_event' in query for query in statements) + + +@pytest.mark.asyncio +async def test_inflight_never_loads_payloads_in_active_recent_or_terminal_rows(engine, service, monkeypatch): + import json + import sqlalchemy as sa + from langbot.pkg.entity.persistence.event_log import EventLog + + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + payload = json.dumps({'text': 'full input', 'attachment': 'x' * (2 * 1024 * 1024)}) + async with AsyncSession(engine) as session: + for message_id, status, timestamp in [ + ('active-message', 'pending', _dt(1)), + ('recent-message', 'success', now), + ('finished-message', 'success', _dt(1)), + ]: + session.add( + persistence_monitoring.MonitoringMessage( + id=message_id, + workspace_uuid=WORKSPACE, + timestamp=timestamp, + bot_id='bot', + bot_name='Bot', + pipeline_id='pipeline', + pipeline_name='Pipeline', + session_id='session', + message_content=payload, + variables=payload, + status=status, + level='info', + event_id=message_id, + run_id='linked-run' if status == 'pending' else None, + execution_owner_id='host' if status == 'pending' else None, + execution_lease_expires_at=now + datetime.timedelta(minutes=1) if status == 'pending' else None, + ) + ) + session.add( + EventLog( + event_id=message_id, + workspace_id=WORKSPACE, + event_type='message.received', + source='platform', + input_summary='small summary', + input_json=payload, + metadata_json=json.dumps({'platform': 'test', 'unused_payload': payload}), + ) + ) + for run_id in ['linked-run', 'standalone-run']: + session.add( + persistence_agent_run.AgentRun( + run_id=run_id, + workspace_id=WORKSPACE, + runner_id='runner', + status='running', + created_at=_dt(1), + authorization_json=payload, + metadata_json=json.dumps( + {'event_type': 'message.received', 'agent_name': 'Agent', 'input': {'text': payload}} + ), + ) + ) + await session.commit() + + linkage_queries = [] + + def reject_payload_reads(connection, cursor, statement, parameters, context, executemany): + for column in [ + 'monitoring_messages.message_content', + 'monitoring_messages.variables', + 'agent_run.authorization_json', + 'event_log.input_json', + ]: + assert column not in statement + if statement.startswith('SELECT agent_run.run_id,') and 'monitoring_messages.id AS message_id' in statement: + linkage_queries.append((statement, parameters)) + + deserialize = service._deserialize_json + + def bounded_metadata(raw): + assert not raw or len(raw) < 10000 + return deserialize(raw) + + monkeypatch.setattr(service, '_deserialize_json', bounded_metadata) + sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads) + try: + snapshot = await service.get_inflight_snapshot(WORKSPACE, [{'source': 'pipeline', 'id': 'finished-message'}]) + rows = {row['id']: row for row in snapshot['items']} + assert rows['active-message']['input_preview'] == 'small summary' + assert rows['recent-message']['status_group'] == 'completed' + assert rows['finished-message']['status_group'] == 'completed' + assert rows['standalone-run']['target_name'] == 'Agent' + assert snapshot['active_total'] == 2 + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads) + assert linkage_queries + async with engine.connect() as connection: + for statement, parameters in linkage_queries: + plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all() + message_lookups = [row[3] for row in plan if 'SEARCH monitoring_messages' in row[3]] + assert message_lookups and all('(id=?)' in lookup for lookup in message_lookups), plan + + +@pytest.mark.asyncio +async def test_inflight_snapshot_is_scoped_and_keeps_terminal_transition(engine, service): + import sqlalchemy as sa + from unittest.mock import AsyncMock + + service._get_execution_summary = AsyncMock(side_effect=AssertionError('No dashboard aggregates')) + service._get_token_coverage = AsyncMock(side_effect=AssertionError('No dashboard aggregates')) + async with AsyncSession(engine) as session: + for workspace, run_id in [(WORKSPACE, 'flight-visible'), ('other-workspace', 'flight-secret')]: + session.add( + persistence_agent_run.AgentRun( + run_id=run_id, + workspace_id=workspace, + runner_id='runner', + status='running', + created_at=_dt(1), + ) + ) + session.add(persistence_agent_run.AgentRunEvent(run_id='flight-visible', sequence=1, type='tool.started')) + await session.commit() + # SSE loaders execute after the HTTP handler's tenant scope has exited. + # Cloud persistence rejects implicit execute_async calls in that context. + from contextlib import asynccontextmanager + + original_execute = service.ap.persistence_mgr.execute_async + unscoped_execute = AsyncMock(side_effect=AssertionError('Cloud persistence requires an explicit tenant scope')) + + @asynccontextmanager + async def tenant_scope(workspace): + assert workspace == WORKSPACE + service.ap.persistence_mgr.execute_async = original_execute + try: + yield + finally: + service.ap.persistence_mgr.execute_async = unscoped_execute + + service.ap.persistence_mgr.tenant_scope = tenant_scope + service.ap.persistence_mgr.execute_async = unscoped_execute + snapshot = await service.get_inflight_snapshot(WORKSPACE) + rows = {r['id']: r for r in snapshot['items']} + assert 'flight-secret' not in rows + assert rows['flight-visible']['progress_event'] == 'tool.started' + async with AsyncSession(engine) as session: + await session.execute( + sa.update(persistence_agent_run.AgentRun) + .where(persistence_agent_run.AgentRun.run_id == 'flight-visible') + .values(status='completed') + ) + await session.commit() + next_snapshot = await service.get_inflight_snapshot(WORKSPACE, [rows['flight-visible']]) + assert next(r for r in next_snapshot['items'] if r['id'] == 'flight-visible')['status_group'] == 'completed' + + +@pytest.mark.asyncio +async def test_inflight_viewers_share_work_and_cleanup(): + import asyncio + from unittest.mock import AsyncMock + from langbot.pkg.utils.inflight import InflightHub + + hub = InflightHub() + loader = AsyncMock(return_value={'items': []}) + first = hub.watch('one', loader) + second = hub.watch('one', loader) + assert (await anext(first))['kind'] == 'snapshot' + assert (await anext(second))['kind'] == 'snapshot' + assert loader.await_count == 1 + other_loader = AsyncMock(return_value={'items': ['private']}) + other = hub.watch('two', other_loader) + assert (await anext(other))['data']['items'] == ['private'] + hub.track('one', ['run']) + hub.notify_run('run', 'tool.started') + loader.return_value = {'items': ['updated']} + frame = await asyncio.wait_for(anext(first), 2) + assert frame['data']['items'] == ['updated'] + assert (await anext(second)) == frame + await first.aclose() + assert 'one' in hub.channels + await second.aclose() + assert 'one' not in hub.channels and 'run' not in hub.run_workspaces + await other.aclose() + assert not hub.channels + + +@pytest.mark.asyncio +async def test_inflight_snapshot_failure_is_logged_and_recovers(caplog): + import asyncio + from unittest.mock import AsyncMock + from langbot.pkg.utils.inflight import InflightHub + + hub = InflightHub() + snapshot = {'items': [], 'active_total': 0, 'truncated': False} + loader = AsyncMock(side_effect=[RuntimeError('snapshot database failure'), snapshot]) + stream = hub.watch('workspace', loader) + try: + assert await asyncio.wait_for(anext(stream), 2) == {'kind': 'unavailable'} + errors = [ + record for record in caplog.records if record.message == 'Failed to load in-flight monitoring snapshot' + ] + assert len(errors) == 1 + assert errors[0].exc_info[0] is RuntimeError + hub.notify('workspace') + assert await asyncio.wait_for(anext(stream), 2) == {'kind': 'snapshot', 'data': snapshot} + finally: + await stream.aclose() + assert not hub.channels diff --git a/tests/unit_tests/api/service/test_monitoring_sessions.py b/tests/unit_tests/api/service/test_monitoring_sessions.py index 7451c9124..5750e466e 100644 --- a/tests/unit_tests/api/service/test_monitoring_sessions.py +++ b/tests/unit_tests/api/service/test_monitoring_sessions.py @@ -21,6 +21,8 @@ pytestmark = pytest.mark.asyncio @pytest.mark.asyncio(loop_scope='module') +@pytest.mark.postgres +@pytest.mark.contract('monitoring_sessions') async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811 import asyncio import importlib @@ -183,6 +185,31 @@ async def test_analysis_fails_closed_and_scopes_statistics(service): assert result['session']['bot_id'] == 'b' +async def test_single_message_analysis_without_tool_calls(service): + from langbot.pkg.api.http.controller.groups.monitoring import validate_monitoring_window + from werkzeug.exceptions import BadRequest + + await service.record_session_start(context('a'), session_id='person_42', **resource('a')) + await service.record_message(context('a'), session_id='person_42', message_content='hi', **resource('a')) + row = (await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.timestamp))).one() + timestamp = row.timestamp + validate_monitoring_window(timestamp, timestamp, allow_point_window=True) + result = await service.get_session_analysis( + context(), 'person_42', bot_id='a', start_time=timestamp, end_time=timestamp + ) + assert result['found'] is True + assert result['message_stats']['total'] == 1 + assert result['tool_calls'] == [] + for start, end in [ + (timestamp + dt.timedelta(seconds=1), timestamp), + (timestamp, timestamp + dt.timedelta(days=366)), + ]: + with pytest.raises(BadRequest): + validate_monitoring_window(start, end, allow_point_window=True) + with pytest.raises(BadRequest): + validate_monitoring_window(timestamp, timestamp) + + async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service): for _ in range(2): await service.record_session_start(context('a'), session_id='person_42', **resource('a')) @@ -218,3 +245,49 @@ async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service): assert count == 1 rows, total = await service.get_sessions(context()) assert total == 1 and rows[0]['bot_id'] == 'b' + + +async def test_bot_session_summary_never_sorts_inline_message_payloads(service): + engine = service.ap.persistence_mgr.get_db_engine() + payload = 'historical inline media ' * 16384 + now = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) + async with engine.begin() as connection: + await connection.execute( + sa.insert(models.MonitoringMessage), + [ + dict( + id=f'message-{index}', + workspace_uuid='workspace', + session_id='group_42', + **resource('a'), + timestamp=now + dt.timedelta(seconds=index), + role='user' if index == 0 else 'assistant', + message_content=payload, + variables=payload, + status='success', + level='info', + user_id='user', + user_name='User', + platform='test', + ) + for index in range(2) + ], + ) + + def reject_payload_reads(connection, cursor, statement, parameters, context, executemany): + assert 'monitoring_messages.message_content' not in statement + assert 'monitoring_messages.variables' not in statement + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads) + try: + rows, total = await service.get_sessions(context('a'), bot_ids=['a']) + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads) + + assert total == 1 + assert rows[0]['session_id'] == 'group_42' + assert rows[0]['message_count'] == 2 + assert rows[0]['user_name'] == 'User' + messages, total = await service.get_messages(context('a'), bot_ids=['a'], session_ids=['group_42']) + assert total == 2 + assert all(message['message_content'] == payload and message['variables'] == payload for message in messages) diff --git a/tests/unit_tests/api/service/test_monitoring_tenancy.py b/tests/unit_tests/api/service/test_monitoring_tenancy.py index 2e2b4216f..97280e15e 100644 --- a/tests/unit_tests/api/service/test_monitoring_tenancy.py +++ b/tests/unit_tests/api/service/test_monitoring_tenancy.py @@ -138,7 +138,8 @@ async def test_same_session_and_resource_ids_do_not_collide(service): assert (await service.get_message_details(context_a, message_b))['found'] is False -async def test_session_search_matches_user_id_or_name_within_workspace(service): +@pytest.mark.parametrize('bot_ids', [None, ['same-bot']]) +async def test_session_search_matches_user_id_or_name_within_workspace(service, bot_ids): context_a = _context(WORKSPACE_A) context_b = _context(WORKSPACE_B) fixtures = [ @@ -159,8 +160,8 @@ async def test_session_search_matches_user_id_or_name_within_workspace(service): user_name=user_name, ) - by_id, id_total = await service.get_sessions(context_a, user_query='customer-42') - by_name, name_total = await service.get_sessions(context_a, user_query='alice') + by_id, id_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='customer-42') + by_name, name_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='alice') assert id_total == 1 assert [session['session_id'] for session in by_id] == ['session-id-match'] @@ -423,3 +424,21 @@ async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path): assert remaining == 3 finally: await engine.dispose() + + +async def test_cloud_cleanup_uses_plan_retention_and_isolates_workspaces(service): + from unittest.mock import AsyncMock + + service.ap.deployment = SimpleNamespace(mode='cloud') + service.ap.entitlement_resolver = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(limits={'chat_history.retention_days': 7}))) + old = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(days=10) + for workspace in (WORKSPACE_A, WORKSPACE_B): + await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringMessage).values( + id=f'policy-{workspace}', workspace_uuid=workspace, timestamp=old, + bot_id='bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline', + message_content='history', session_id='session', status='success', level='info', + )) + deleted = await service.cleanup_expired_records(_context(WORKSPACE_A), retention_days=90) + assert deleted['monitoring_messages'] == 1 + rows = await service.ap.persistence_mgr.execute_async(sqlalchemy.select(MonitoringMessage.workspace_uuid)) + assert rows.scalars().all() == [WORKSPACE_B] diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py index 901dfc608..362af7209 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration.py +++ b/tests/unit_tests/api/service/test_pipeline_migration.py @@ -367,6 +367,9 @@ async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env await service.update_pipeline(context(), 'one', {'name': 'renamed'}) configs, backups = await rows(env) assert configs['one'] == SOURCE and not backups + async with env.engine.connect() as conn: + name = (await conn.execute(sa.select(LegacyPipeline.name).where(LegacyPipeline.uuid == 'one'))).scalar_one() + assert name == 'renamed' @pytest.mark.asyncio @@ -777,7 +780,8 @@ async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env): await conn.execute(sa.delete(PluginSetting)) async def install(source, info, task_context): - assert info['plugin_version'] == '1.0' + assert source == env.m.PluginInstallSource.MARKETPLACE + assert info == {'plugin_author': 'langbot-team', 'plugin_name': 'TestAgent', 'plugin_version': '1.0'} async with env.engine.begin() as conn: await conn.execute( sa.insert(PluginSetting).values( @@ -848,7 +852,14 @@ async def test_all_mode_has_no_fifty_pipeline_limit(env): ) response, metadata = await execute_all(env, install_plugins=False) assert len(response['pipeline_uuids']) == 53 + expected = {'one', 'two', *(f'extra-{i}' for i in range(51))} + assert {r['pipeline_uuid'] for r in metadata['results']} == expected + assert len(metadata['results']) == 53 assert all(r['state'] == 'migrated' for r in metadata['results']) + configs, backups = await rows(env) + assert len(backups) == 53 + assert all(configs[pipeline_uuid]['ai']['runner']['id'] == RID for pipeline_uuid in expected) + assert configs['foreign'] == SOURCE @pytest.mark.asyncio diff --git a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py index b9a9801d9..e332d67bc 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py +++ b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py @@ -23,12 +23,6 @@ env = existing.env NATIVE = 'langbot.pkg.provider.runners.difysvapi' -@pytest.fixture(autouse=True) -async def interaction_schema(env): - async with env.engine.begin() as conn: - await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True)) - - async def add_request(env, status='pending', workspace=WS, pipeline='one'): async with env.engine.begin() as conn: await conn.execute( @@ -224,11 +218,3 @@ async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch): configs, snapshots = await rows(env) assert configs['one'] == SOURCE and not snapshots env.ap.pipeline_mgr.publish_pipeline.assert_not_called() - - -# Also exercise the unchanged regression assertions with this module's -# interaction-schema fixture. The original tests remain in their own module. - -for _name, _test in vars(existing).items(): - if _name.startswith('test_') and callable(_test): - globals()['test_existing_' + _name[5:]] = _test diff --git a/tests/unit_tests/api/service/test_pipeline_migration_resources.py b/tests/unit_tests/api/service/test_pipeline_migration_resources.py index aa169a569..c0b865ff6 100644 --- a/tests/unit_tests/api/service/test_pipeline_migration_resources.py +++ b/tests/unit_tests/api/service/test_pipeline_migration_resources.py @@ -103,20 +103,28 @@ async def test_valid_local_resources_and_real_descriptor_options(local): @pytest.mark.asyncio -@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool']) -async def test_foreign_or_unresolved_resource_is_denied(local, resource): +async def test_foreign_or_unresolved_model_is_denied(local): env, _, config, plan = local - if resource == 'model': - config['model']['primary'] = 'model_foreign' - elif resource == 'kb': + config['model']['primary'] = 'model_foreign' + async with env.pm.tenant_scope(WS): + with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'): + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('resource', ['kb', 'mcp', 'tool']) +async def test_foreign_or_unresolved_reference_is_pruned(local, resource): + env, _, config, plan = local + if resource == 'kb': config['knowledge-bases'] = ['kb_foreign'] elif resource == 'mcp': config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign' else: config['tools'] = ['foreign_tool'] async with env.pm.tenant_scope(WS): - with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'): - await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan) + key = {'kb': 'knowledge-bases', 'mcp': 'mcp-resources', 'tool': 'tools'}[resource] + assert config[key] == [] @pytest.mark.asyncio diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index dc8cb79d5..c1d65ede5 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -17,6 +17,7 @@ import pytest from unittest.mock import AsyncMock, Mock, patch, mock_open from types import SimpleNamespace import uuid +import sqlalchemy import json from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order @@ -145,8 +146,12 @@ class TestPipelineServiceGetPipelines: ) # Verify - assert len(result) == 2 - ap.persistence_mgr.execute_async.assert_called_once() + assert result == [{'uuid': 'uuid-1', 'name': 'Pipeline 1'}, {'uuid': 'uuid-2', 'name': 'Pipeline 2'}] + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert len(statement._order_by_clauses) == 1 + assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.created_at.desc()) + assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID) async def test_get_pipelines_sort_by_updated_at_asc(self): """Returns pipelines sorted by updated_at ascending.""" @@ -164,7 +169,11 @@ class TestPipelineServiceGetPipelines: await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC') # Verify - execute was called with sort parameters - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert len(statement._order_by_clauses) == 1 + assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.updated_at.asc()) + assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID) class TestPipelineServiceGetPipeline: @@ -271,7 +280,19 @@ class TestPipelineServiceCreatePipeline: # Verify assert bot_uuid is not None - assert len(bot_uuid) == 36 # UUID format + assert uuid.UUID(bot_uuid).version == 4 + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.table.name == LegacyPipeline.__tablename__ + values = statement.compile().params + assert values['uuid'] == bot_uuid + assert values['workspace_uuid'] == WORKSPACE_UUID + assert values['name'] == 'New Pipeline' + assert values['config'] == default_config + assert values['for_version'] == '1.0.0' + assert values['is_default'] is False + service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, bot_uuid, include_secret=True) + ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value) async def test_create_pipeline_as_default(self): """Creates pipeline with is_default=True.""" @@ -305,7 +326,10 @@ class TestPipelineServiceCreatePipeline: await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True) # Verify - execute was called - ap.persistence_mgr.execute_async.assert_called() + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.compile().params['is_default'] is True async def test_create_pipeline_sets_default_extensions_preferences(self): """Sets default extensions_preferences when not provided.""" @@ -515,21 +539,19 @@ class TestPipelineServiceDeletePipeline: ap.persistence_mgr.execute_async.assert_called_once() async def test_delete_pipeline_nonexistent_uuid(self): - """Delete operation completes even for nonexistent UUID.""" + """Missing persisted Pipelines do not remove runtime instances.""" # Setup ap = SimpleNamespace() ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0)) ap.pipeline_mgr = SimpleNamespace() ap.pipeline_mgr.remove_pipeline = AsyncMock() service = PipelineService(ap) - # Execute - should not raise - await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid') - - # Verify - ap.pipeline_mgr.remove_pipeline.assert_called_once() + with pytest.raises(WorkspaceNotFoundError, match='Pipeline not found'): + await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid') + ap.pipeline_mgr.remove_pipeline.assert_not_awaited() class TestPipelineServiceCopyPipeline: @@ -627,7 +649,23 @@ class TestPipelineServiceCopyPipeline: # Verify assert new_uuid is not None - assert len(new_uuid) == 36 # UUID format + assert uuid.UUID(new_uuid).version == 4 + insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0] + assert insert.is_insert + values = insert.compile().params + assert values['uuid'] == new_uuid + assert values['workspace_uuid'] == WORKSPACE_UUID + assert values['name'] == 'Original Pipeline (Copy)' + assert values['description'] == 'Original description' + assert values['stages'] == ['Stage1', 'Stage2'] + assert values['config'] == {'key': 'value'} + assert values['extensions_preferences']['enable_all_plugins'] is False + assert values['extensions_preferences']['plugins'] == [] + query = ap.persistence_mgr.execute_async.await_args_list[0].args[0] + assert query.whereclause.compare( + sqlalchemy.and_(LegacyPipeline.uuid == 'original-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID) + ) + service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, new_uuid, include_secret=True) async def test_copy_pipeline_is_not_default(self): """Copy is never set as default.""" @@ -659,7 +697,20 @@ class TestPipelineServiceCopyPipeline: await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid') # Verify - pipeline_mgr.load_pipeline called (copy created) - ap.pipeline_mgr.load_pipeline.assert_called_once() + ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value) + insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0] + assert insert.is_insert + assert insert.compile().params['is_default'] is False + + +def _persisted_extensions(ap): + updates = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_update] + assert len(updates) == 1 + statement = updates[0] + assert statement.whereclause.compare( + sqlalchemy.and_(LegacyPipeline.uuid == 'test-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID) + ) + return statement.compile().params['extensions_preferences'] class TestPipelineServiceUpdatePipelineExtensions: @@ -731,7 +782,9 @@ class TestPipelineServiceUpdatePipelineExtensions: ) # Verify - ap.persistence_mgr.execute_async.assert_called() + persisted = _persisted_extensions(ap) + assert persisted['enable_all_plugins'] is False + assert persisted['plugins'] == [{'author': 'test', 'name': 'plugin-1'}] async def test_update_extensions_sets_mcp_servers(self): """Updates MCP servers in extensions_preferences.""" @@ -782,7 +835,9 @@ class TestPipelineServiceUpdatePipelineExtensions: ) # Verify - ap.persistence_mgr.execute_async.assert_called() + persisted = _persisted_extensions(ap) + assert persisted['enable_all_mcp_servers'] is False + assert persisted['mcp_servers'] == ['mcp-server-1'] async def test_update_extensions_none_mcp_servers_keeps_existing(self): """Does not modify mcp_servers when bound_mcp_servers is None.""" @@ -825,7 +880,7 @@ class TestPipelineServiceUpdatePipelineExtensions: await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[]) # Verify - persistence was called - ap.persistence_mgr.execute_async.assert_called() + assert _persisted_extensions(ap)['mcp_servers'] == ['existing-server'] 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.""" @@ -863,10 +918,9 @@ class TestPipelineServiceUpdatePipelineExtensions: await service.update_pipeline_extensions(WORKSPACE_UUID, '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'} - ] + persisted = _persisted_extensions(ap) + assert persisted['mcp_resource_agent_read_enabled'] is False + assert persisted['mcp_resources'] == [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}] class TestPipelineSecretRoundtrip: diff --git a/tests/unit_tests/api/service/test_provider_cascade.py b/tests/unit_tests/api/service/test_provider_cascade.py index 1fd58e98d..971dafc3b 100644 --- a/tests/unit_tests/api/service/test_provider_cascade.py +++ b/tests/unit_tests/api/service/test_provider_cascade.py @@ -15,6 +15,7 @@ from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvi from langbot.pkg.api.http.service.provider import ModelProviderService from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.provider.modelmgr.modelmgr import ModelManager @@ -51,7 +52,7 @@ async def deletion(tmp_path): } async with engine.begin() as conn: - for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES): + for table in (User, Workspace, WorkspaceMetadata, ModelProvider, CodexCredential, *MODEL_TYPES): await conn.run_sync(table.__table__.create) for workspace in contexts: await conn.execute( diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index b99509e5b..62612892d 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -14,12 +14,14 @@ Source: src/langbot/pkg/api/http/service/provider.py from __future__ import annotations import pytest +import sqlalchemy +from uuid import UUID from contextlib import nullcontext from unittest.mock import AsyncMock, Mock from types import SimpleNamespace from langbot.pkg.api.http.service.provider import ModelProviderService -from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel +from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel from langbot.pkg.workspace.errors import WorkspaceNotFoundError @@ -300,7 +302,23 @@ class TestModelProviderServiceCreateProvider: # Verify - UUID is generated assert provider_uuid is not None - assert len(provider_uuid) == 36 # UUID format + assert UUID(provider_uuid).version == 4 + expected = { + 'uuid': provider_uuid, + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Provider', + 'requester': 'openai', + 'base_url': 'https://api.openai.com', + 'api_keys': ['key'], + } + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.table.name == ModelProvider.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + ap.model_mgr.load_provider.assert_awaited_once_with(WORKSPACE_UUID, expected) + ap.model_mgr.cache_provider.assert_awaited_once_with(WORKSPACE_UUID, runtime_provider) async def test_create_provider_loads_to_runtime(self): """Loads provider to runtime model_mgr.""" @@ -362,7 +380,15 @@ class TestModelProviderServiceUpdateProvider: ) # Verify - reload called - ap.model_mgr.reload_provider.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.model_mgr.reload_provider.assert_awaited_once_with(WORKSPACE_UUID, 'existing-uuid') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert 'uuid' not in statement.compile().params + assert statement.compile().params.get('name') == 'Updated Name' + assert statement.whereclause.compare( + sqlalchemy.and_(ModelProvider.uuid == 'existing-uuid', ModelProvider.workspace_uuid == WORKSPACE_UUID) + ) async def test_update_provider_reloads_runtime(self): """Reloads provider in runtime after update.""" @@ -432,16 +458,20 @@ class TestModelProviderServiceGetProviderModelCounts: rerank_result = Mock() rerank_result.scalar = Mock(return_value=1) - call_count = 0 + results_by_table = { + LLMModel.__tablename__: (LLMModel, llm_result), + EmbeddingModel.__tablename__: (EmbeddingModel, embedding_result), + RerankModel.__tablename__: (RerankModel, rerank_result), + } async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return llm_result - elif call_count == 2: - return embedding_result - return rerank_result + model_type, result = results_by_table[query.get_final_froms()[0].name] + assert query.whereclause.compare( + sqlalchemy.and_( + model_type.provider_uuid == 'provider-uuid', model_type.workspace_uuid == WORKSPACE_UUID + ) + ) + return result ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) @@ -602,7 +632,12 @@ class TestModelProviderServiceFindOrCreateProvider: ) # Verify - name should be parsed from URL (api.example.com) - ap.model_mgr.load_provider.assert_called_once() + ap.model_mgr.load_provider.assert_awaited_once() + assert ap.model_mgr.load_provider.await_args.args[0] == WORKSPACE_UUID + assert ap.model_mgr.load_provider.await_args.args[1]['name'] == 'api.example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_insert + assert statement.compile().params.get('name') == 'api.example.com' assert result_uuid is not None @@ -624,6 +659,16 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys: # Execute await service.update_space_model_provider_api_keys(WORKSPACE_UUID, 'space-api-key') + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.is_update + assert statement.compile().params.get('api_keys') == ['space-api-key'] + assert statement.whereclause.compare( + sqlalchemy.and_( + ModelProvider.uuid == '00000000-0000-0000-0000-000000000000', + ModelProvider.workspace_uuid == WORKSPACE_UUID, + ) + ) # Verify - update and reload called for Space provider UUID ap.model_mgr.reload_provider.assert_called_once_with( WORKSPACE_UUID, diff --git a/tests/unit_tests/api/service/test_session_context_reset.py b/tests/unit_tests/api/service/test_session_context_reset.py new file mode 100644 index 000000000..43effa679 --- /dev/null +++ b/tests/unit_tests/api/service/test_session_context_reset.py @@ -0,0 +1,156 @@ +import contextlib +import datetime +import json +from types import SimpleNamespace + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.service.monitoring import MonitoringService +from langbot.pkg.agent.runner.context_reset import get_reset_generation +from langbot.pkg.agent.runner.transcript_store import TranscriptStore +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.monitoring import MonitoringSession +from langbot.pkg.entity.persistence.runner_state import RunnerState +from langbot.pkg.entity.persistence.agent_run import AgentRun +from langbot.pkg.entity.persistence.transcript import Transcript + + +class Persistence: + def __init__(self, engine): + self.engine = engine + + def get_db_engine(self): + return self.engine + + @contextlib.asynccontextmanager + async def tenant_uow(self, workspace): + async with self.engine.begin() as conn: + self.conn = conn + yield + + async def execute_async(self, statement): + return await self.conn.execute(statement) + + +@pytest.fixture +async def context_service(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "reset.db"}') + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + sa.insert(MonitoringSession).values( + workspace_uuid='w', + bot_id='b', + session_id='person_u', + bot_name='Bot', + pipeline_id='p', + pipeline_name='Pipeline', + start_time=datetime.datetime.utcnow(), + last_activity=datetime.datetime.utcnow(), + ) + ) + for workspace, bot, conversation, scope in [ + ('w', 'b', 'person_u', 'conversation'), + ('w', 'b2', 'person_u', 'conversation'), + ('w2', 'b', 'person_u', 'conversation'), + ('w', 'b', 'person_other', 'conversation'), + ('w', 'b', 'person_u', 'actor'), + ]: + await conn.execute( + sa.insert(RunnerState).values( + workspace_id=workspace, + bot_id=bot, + conversation_id=conversation, + scope=scope, + scope_key=f'{workspace}:{bot}:{conversation}:{scope}', + state_key='checkpoint', + runner_id='runner', + binding_identity='binding', + value_json=json.dumps('old'), + ) + ) + yield MonitoringService(SimpleNamespace(persistence_mgr=Persistence(engine))), engine + await engine.dispose() + + +@pytest.mark.asyncio +async def test_reset_preserves_logs_and_isolates_context(context_service): + service, engine = context_service + store = TranscriptStore(engine) + for bot in ['b', 'b2']: + await store.append_transcript(None, 'event', 'person_u', 'user', bot_id=bot, workspace_id='w', content='old') + await service.reset_session_context('w', 'b', 'person_u') + assert await get_reset_generation(engine, 'w', 'b', 'person_u') + async with engine.connect() as conn: + assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2 + assert (await conn.execute(sa.select(sa.func.count()).select_from(MonitoringSession))).scalar() == 1 + remaining = ( + await conn.execute( + sa.select( + RunnerState.workspace_id, RunnerState.bot_id, RunnerState.conversation_id, RunnerState.scope + ).where(RunnerState.runner_id == 'runner') + ) + ).all() + assert set(remaining) == { + ('w', 'b2', 'person_u', 'conversation'), + ('w2', 'b', 'person_u', 'conversation'), + ('w', 'b', 'person_other', 'conversation'), + ('w', 'b', 'person_u', 'actor'), + } + page = await store.page_transcript('person_u', bot_id='b', workspace_id='w') + assert not page[0] + assert (await store.page_transcript('person_u', bot_id='b2', workspace_id='w'))[0] + await store.append_transcript(None, 'new', 'person_u', 'user', bot_id='b', workspace_id='w', content='fresh') + assert len((await store.page_transcript('person_u', bot_id='b', workspace_id='w'))[0]) == 1 + await service.reset_session_context('w', 'b', 'person_u') + assert not (await store.page_transcript('person_u', bot_id='b', workspace_id='w'))[0] + + +@pytest.mark.asyncio +async def test_reset_refuses_wrong_scope_and_running_tasks(context_service): + service, engine = context_service + for workspace, bot in [('other', 'b'), ('w', 'other')]: + with pytest.raises(LookupError): + await service.reset_session_context(workspace, bot, 'person_u') + async with engine.begin() as conn: + await conn.execute( + sa.insert(AgentRun).values( + run_id='run', + event_id='evt', + agent_id='agent', + binding_id='binding', + runner_id='runner', + workspace_id='w', + bot_id='b', + conversation_id='person_u', + status='running', + ) + ) + with pytest.raises(RuntimeError): + await service.reset_session_context('w', 'b', 'person_u') + assert await get_reset_generation(engine, 'w', 'b', 'person_u') is None + + +@pytest.mark.asyncio +async def test_reset_starts_new_external_runner_conversation(context_service): + from unit_tests.agent.test_state_store import FakeBinding, FakeEventEnvelope, make_descriptor + from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore + + service, engine = context_service + store = PersistentStateStore(engine) + event = FakeEventEnvelope(workspace_id='w', bot_id='b', conversation_id='person_u') + binding = FakeBinding() + descriptor = make_descriptor() + await store.apply_update_from_event( + event, binding, descriptor, 'conversation', 'external.conversation_id', 'remote-old', None + ) + assert (await store.build_snapshot_from_event(event, binding, descriptor))['conversation'][ + 'external.conversation_id' + ] == 'remote-old' + await service.reset_session_context('w', 'b', 'person_u') + snapshot = await store.build_snapshot_from_event(event, binding, descriptor) + generation = await get_reset_generation(engine, 'w', 'b', 'person_u') + assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'} + assert snapshot['conversation']['external.conversation_id'] == generation diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py index a05754af5..043d8c6c1 100644 --- a/tests/unit_tests/api/service/test_space_service.py +++ b/tests/unit_tests/api/service/test_space_service.py @@ -15,6 +15,7 @@ from __future__ import annotations from collections import OrderedDict import json +import sqlalchemy from urllib.parse import parse_qs, urlsplit import pytest @@ -25,6 +26,7 @@ import time from langbot.pkg.api.http.service.space import SpaceService from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider from langbot.pkg.utils import constants @@ -157,6 +159,8 @@ class TestSpaceServiceGetUserByEmail: # Verify assert result is not None assert result.user == 'found@example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.user == 'found@example.com') async def test_get_user_by_email_not_found(self): """Returns None when user not found.""" @@ -478,6 +482,9 @@ class TestSpaceServiceRefreshToken: # Verify assert result['access_token'] == 'new_access_token' + mock_session_obj.post.assert_called_once_with( + 'https://space.langbot.app/api/v1/accounts/token/refresh', json={'refresh_token': 'old_refresh_token'} + ) async def test_refresh_token_api_error(self): """Raises ValueError on API error.""" @@ -662,6 +669,9 @@ class TestSpaceServiceGetUserInfoRaw: # Verify assert result['email'] == 'test@example.com' assert result['credits'] == 100 + mock_session_obj.get.assert_called_once_with( + 'https://space.langbot.app/api/v1/accounts/me', headers={'Authorization': 'Bearer access_token'} + ) async def test_get_user_info_raw_api_error(self): """Raises ValueError on API error.""" @@ -737,7 +747,8 @@ class TestSpaceServiceGetUserInfo: result = await service.get_user_info('test@example.com') # Verify - assert result['email'] == 'test@example.com' + assert result == {'email': 'test@example.com', 'credits': 100} + service.get_user_info_raw.assert_awaited_once_with('valid_token') class TestSpaceServiceGetModels: @@ -980,6 +991,20 @@ class TestSpaceServiceGetModelSelection: service.get_model_selection.assert_awaited_once_with('chat') model_mgr.sync_new_models_from_space.assert_awaited_once() assert persistence.execute_async.await_count == 2 + execution = model_mgr.sync_new_models_from_space.await_args.args[0] + assert (execution.instance_uuid, execution.workspace_uuid, execution.placement_generation) == ( + 'instance', + 'workspace', + 1, + ) + for call in persistence.execute_async.await_args_list: + assert call.args[0].whereclause.compare( + sqlalchemy.and_( + LLMModel.workspace_uuid == 'workspace', + ModelProvider.requester == 'space-chat-completions', + sqlalchemy.or_(LLMModel.uuid == 'best-upstream-uuid', LLMModel.name == 'best-chat-model'), + ) + ) class TestSpaceServiceCreditsCache: @@ -1025,3 +1050,45 @@ class TestSpaceServiceCreditsCache: assert result == 500 assert 'test@example.com' in service._credits_cache assert service._credits_cache['test@example.com'][0] == 500 + + +@pytest.mark.parametrize('status', [200, 503]) +async def test_assistant_recommendation_uses_dedicated_endpoint_without_fallback(status): + service = SpaceService(SimpleNamespace(instance_config=SimpleNamespace(data={}))) + service.get_model_selection = AsyncMock() + service._resolve_recommended_model = AsyncMock(return_value={'uuid': 'local', 'name': 'deepseek-v4-flash'}) + response = MagicMock(status=status) + _set_response_body( + response, + { + 'code': 0, + 'data': { + 'model': { + 'uuid': 'upstream', + 'model_id': 'deepseek-v4-flash', + 'category': 'chat', + 'provider': 'deepseek', + 'status': 'active', + } + }, + }, + ) + session = MagicMock() + session.get.return_value.__aenter__ = AsyncMock(return_value=response) + session.get.return_value.__aexit__ = AsyncMock(return_value=None) + context = object() + with patch('langbot.pkg.api.http.service.space.httpclient.get_session', return_value=session): + if status == 200: + assert await service.get_recommended_assistant_model(context) == { + 'uuid': 'local', + 'name': 'deepseek-v4-flash', + } + args = service._resolve_recommended_model.await_args.args + assert args[0] is context + assert args[1].model_id == 'deepseek-v4-flash' + else: + with pytest.raises(ValueError, match='No recommended assistant model'): + await service.get_recommended_assistant_model(context) + service._resolve_recommended_model.assert_not_awaited() + service.get_model_selection.assert_not_awaited() + session.get.assert_called_once_with('https://space.langbot.app/api/v1/models/assistant/recommendation') diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 12b440b32..6d28212f4 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -13,9 +13,14 @@ Source: src/langbot/pkg/api/http/service/user.py from __future__ import annotations +import asyncio import pytest import jwt import datetime + +import sqlalchemy +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError from unittest.mock import AsyncMock, Mock from types import SimpleNamespace @@ -24,6 +29,7 @@ from langbot.pkg.api.http.service.user import ( UserService, ) from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User +from langbot.pkg.entity.persistence.workspace import WorkspaceMembership, MembershipRole, MembershipStatus from langbot.pkg.entity.errors.account import ( AccountEmailMismatchError, SpaceAccountBindingRequiredError, @@ -265,6 +271,8 @@ class TestUserServiceGetUserByEmail: # Verify assert result is not None assert result.user == 'found@example.com' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.normalized_email == 'found@example.com') async def test_get_user_by_email_not_found(self): """Returns None when user not found.""" @@ -323,6 +331,8 @@ class TestUserServiceGetUserBySpaceAccountUuid: # Verify assert result is not None assert result.space_account_uuid == 'space-uuid-123' + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert statement.whereclause.compare(User.space_account_uuid == 'space-uuid-123') async def test_get_user_by_space_uuid_not_found(self): """Returns None when Space UUID not found.""" @@ -357,7 +367,7 @@ class TestUserServiceAuthenticate: service = UserService(ap) # Execute & Verify - with pytest.raises(ValueError, match='用户不存在'): + with pytest.raises(ValueError, match='Resource not found'): await service.authenticate('nonexistent@example.com', 'password') async def test_authenticate_space_user_without_password_raises_error(self): @@ -377,46 +387,31 @@ class TestUserServiceAuthenticate: service = UserService(ap) # Execute & Verify - with pytest.raises(ValueError, match='请使用 LangBot 账号登录'): + with pytest.raises(ValueError, match='Please sign in with your LangBot Account'): await service.authenticate('space@example.com', 'password') class TestUserServiceGenerateJwtToken: """Tests for generate_jwt_token method.""" - async def test_generate_jwt_token_returns_valid_token(self): - """Generates valid JWT token.""" + @pytest.mark.parametrize('expire', [3600, 7200]) + async def test_generate_jwt_token_returns_valid_token(self, expire): + """The signed token retains the account and configured lifetime.""" # Setup ap = SimpleNamespace() ap.instance_config = SimpleNamespace() - ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}} + ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': expire}}} service = UserService(ap) # Execute + before = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) token = await service.generate_jwt_token('test@example.com') + after = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - # Verify - JWT format (base64 encoded parts) - assert token is not None - assert len(token) > 0 - parts = token.split('.') - assert len(parts) == 3 # JWT has 3 parts - - async def test_generate_jwt_token_custom_expire(self): - """Generates token with custom expiry.""" - # Setup - ap = SimpleNamespace() - ap.instance_config = SimpleNamespace() - ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}} - - service = UserService(ap) - - # Execute - token = await service.generate_jwt_token('test@example.com') - - # Verify - assert token is not None - + claims = jwt.decode(token, 'test_secret', algorithms=['HS256'], options={'verify_aud': False}) + assert claims['user'] == 'test@example.com' + assert before + expire <= claims['exp'] <= after + expire class TestUserServiceVerifyJwtToken: @@ -511,10 +506,17 @@ class TestUserServiceResetPassword: service = UserService(ap) # Execute - await service.reset_password('test@example.com', 'new_password') + await service.reset_password(' Test@Example.COM ', 'new_password') - # Verify - execute_async was called with update - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + statement = ap.persistence_mgr.execute_async.await_args.args[0] + assert isinstance(statement, sqlalchemy.sql.dml.Update) + assert statement.table.name == User.__tablename__ + params = statement.compile().params + assert params['normalized_email_1'] == 'test@example.com' + assert PasswordHasher().verify(params['password'], 'new_password') + with pytest.raises(VerifyMismatchError): + PasswordHasher().verify(params['password'], 'old-password') class TestUserServiceChangePassword: @@ -625,6 +627,16 @@ class TestUserServiceSetPassword: await service.set_password('haspass@example.com', 'new_password') +def _space_user_write(persistence, operation, credentials): + persistence.execute_async.assert_awaited_once() + statement = persistence.execute_async.await_args.args[0] + assert getattr(statement, f'is_{operation}') + assert statement.table.name == User.__tablename__ + values = statement.compile().params + assert {key: values.get(key) for key in credentials} == credentials + return statement, values + + class TestUserServiceCreateOrUpdateSpaceUser: """Tests for create_or_update_space_user method.""" @@ -661,7 +673,17 @@ class TestUserServiceCreateOrUpdateSpaceUser: ) # Verify - update was called and user returned - ap.persistence_mgr.execute_async.assert_called() + statement, values = _space_user_write( + ap.persistence_mgr, + 'update', + { + 'space_access_token': 'new_access_token', + 'space_refresh_token': 'new_refresh_token', + 'space_api_key': 'new_api_key', + }, + ) + assert statement.whereclause.compare(User.space_account_uuid == 'existing-space-uuid') + assert isinstance(values['space_access_token_expires_at'], datetime.datetime) assert updated_user.space_account_uuid == 'existing-space-uuid' async def test_cloud_login_updates_only_the_projected_space_account(self): @@ -694,7 +716,23 @@ class TestUserServiceCreateOrUpdateSpaceUser: ) assert result is projected - persistence.execute_async.assert_awaited_once() + statement, values = _space_user_write( + persistence, + 'update', + { + 'space_access_token': 'access-token', + 'space_refresh_token': 'refresh-token', + 'space_api_key': 'api-key', + }, + ) + assert statement.whereclause.compare( + sqlalchemy.and_( + User.uuid == 'projected-space-uuid', + User.space_account_uuid == 'projected-space-uuid', + User.source == AccountSource.CLOUD_PROJECTION.value, + ) + ) + assert isinstance(values['space_access_token_expires_at'], datetime.datetime) async def test_cloud_login_never_creates_an_unprojected_account(self): persistence = SimpleNamespace(execute_async=AsyncMock()) @@ -774,6 +812,20 @@ class TestUserServiceCreateOrUpdateSpaceUser: # Verify assert result.space_account_uuid == 'new-space-uuid' + _space_user_write( + ap.persistence_mgr, + 'insert', + { + 'user': 'newspace@example.com', + 'normalized_email': 'newspace@example.com', + 'password': '', + 'account_type': 'space', + 'space_account_uuid': 'new-space-uuid', + 'space_access_token': 'access_token', + 'space_refresh_token': 'refresh_token', + 'space_api_key': 'api_key', + }, + ) async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self): """Unknown Space email is distinct from an existing local Account collision.""" @@ -918,6 +970,17 @@ class TestUserServiceCreateOrUpdateSpaceUser: # Verify assert result is not None assert result.space_account_uuid == 'noexpiry-uuid' + _space_user_write( + ap.persistence_mgr, + 'insert', + { + 'space_account_uuid': 'noexpiry-uuid', + 'space_access_token': 'token', + 'space_refresh_token': 'refresh', + 'space_api_key': 'key', + 'space_access_token_expires_at': None, + }, + ) async def test_bind_space_account_rejects_different_email(self): service = UserService(SimpleNamespace()) @@ -945,13 +1008,23 @@ class TestUserServiceCreateOrUpdateSpaceUser: async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self): service = UserService(SimpleNamespace()) owner = _create_mock_user('owner@example.com', password='pw') - service.ap.persistence_mgr = SimpleNamespace( - current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)), - ) + session = SimpleNamespace(scalar=AsyncMock(return_value=owner)) + service.ap.persistence_mgr = SimpleNamespace(current_session=lambda: session) resolved = await service.get_workspace_owner('workspace-1') assert resolved is owner + session.scalar.assert_awaited_once() + statement = session.scalar.await_args.args[0] + assert statement.whereclause.compare( + sqlalchemy.and_( + WorkspaceMembership.workspace_uuid == 'workspace-1', + WorkspaceMembership.role == MembershipRole.OWNER.value, + WorkspaceMembership.status == MembershipStatus.ACTIVE.value, + User.status == AccountStatus.ACTIVE.value, + ) + ) + assert statement.get_final_froms()[0].onclause.compare(WorkspaceMembership.account_uuid == User.uuid) class TestUserServiceLoginCapabilities: @@ -984,4 +1057,5 @@ class TestUserServiceCreateUserLock: # Verify lock exists assert hasattr(service, '_create_user_lock') - assert service._create_user_lock is not None + assert isinstance(service._create_user_lock, asyncio.Lock) + assert not service._create_user_lock.locked() diff --git a/tests/unit_tests/api/service/test_webhook_service.py b/tests/unit_tests/api/service/test_webhook_service.py index 64f58de05..df212363e 100644 --- a/tests/unit_tests/api/service/test_webhook_service.py +++ b/tests/unit_tests/api/service/test_webhook_service.py @@ -125,6 +125,24 @@ class TestWebhookServiceGetWebhooks: assert result[1]['name'] == 'Webhook 2' +def _assert_webhook_write(ap, operation, expected): + statements = [ + call.args[0] + for call in ap.persistence_mgr.execute_async.await_args_list + if getattr(call.args[0], f'is_{operation}') + ] + assert len(statements) == 1 + statement = statements[0] + assert statement.table.name == Webhook.__tablename__ + if operation == 'insert': + values = statement.compile().params + assert {key: values.get(key) for key in expected} == expected + else: + assert statement.whereclause.compare( + sqlalchemy.and_(Webhook.id == expected, Webhook.workspace_uuid == WORKSPACE_UUID) + ) + + class TestWebhookServiceCreateWebhook: """Tests for create_webhook method.""" @@ -187,6 +205,17 @@ class TestWebhookServiceCreateWebhook: assert result['url'] == 'http://new.example.com/webhook' assert result['description'] == 'New Description' assert result['enabled'] is True + _assert_webhook_write( + ap, + 'insert', + { + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'New Webhook', + 'url': 'http://new.example.com/webhook', + 'description': 'New Description', + 'enabled': True, + }, + ) async def test_create_webhook_defaults(self): """Creates webhook with default description and enabled.""" @@ -236,6 +265,17 @@ class TestWebhookServiceCreateWebhook: # Verify defaults assert result['description'] == '' assert result['enabled'] is True + _assert_webhook_write( + ap, + 'insert', + { + 'workspace_uuid': WORKSPACE_UUID, + 'name': 'Minimal Webhook', + 'url': 'http://minimal.example.com', + 'description': '', + 'enabled': True, + }, + ) async def test_create_webhook_disabled(self): """Creates webhook with enabled=False.""" @@ -271,6 +311,7 @@ class TestWebhookServiceCreateWebhook: # Verify assert result['enabled'] is False + _assert_webhook_write(ap, 'insert', {'enabled': False, 'workspace_uuid': WORKSPACE_UUID}) async def test_create_webhook_rejects_workspace_at_capacity(self): ap = SimpleNamespace( @@ -377,104 +418,59 @@ class TestWebhookServiceGetWebhook: class TestWebhookServiceUpdateWebhook: """Tests for update_webhook method.""" - async def test_update_webhook_name_only(self): - """Updates only the name field.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result()) + @pytest.mark.parametrize( + 'changes', + [ + {'name': 'Updated Name'}, + {'url': 'https://updated.example.com'}, + {'description': 'Updated description'}, + {'enabled': False}, + { + 'name': 'All Updated', + 'url': 'https://all.updated.example.com', + 'description': 'All updated description', + 'enabled': False, + }, + ], + ids=['name-only', 'url-only', 'description-only', 'enabled-only', 'all-fields'], + ) + async def test_update_webhook_persists_requested_fields(self, tenant_webhook_service, changes): + """Persist requested fields without changing omitted fields or another Workspace.""" + original = { + 'name': 'Original Name', + 'url': 'https://original.example.com', + 'description': 'Original description', + 'enabled': True, + } + created = await tenant_webhook_service.create_webhook(ISOLATION_WORKSPACE_A, **original) + other = await tenant_webhook_service.create_webhook(ISOLATION_WORKSPACE_B, **original) - service = WebhookService(ap) + assert await tenant_webhook_service.update_webhook(ISOLATION_WORKSPACE_A, created['id'], **changes) is True - # Execute - await service.update_webhook(WORKSPACE_UUID, 1, name='Updated Name') + updated = await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id'], include_secret=True) + assert {field: updated[field] for field in original} == original | changes + assert updated['id'] == created['id'] + assert updated['workspace_uuid'] == ISOLATION_WORKSPACE_A + assert ( + await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_B, other['id'], include_secret=True) == other + ) - # Verify - ap.persistence_mgr.execute_async.assert_called_once() - - async def test_update_webhook_url_only(self): - """Updates only the url field.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result()) - - service = WebhookService(ap) - - # Execute - await service.update_webhook(WORKSPACE_UUID, 1, url='http://updated.example.com') - - # Verify - ap.persistence_mgr.execute_async.assert_called_once() - - async def test_update_webhook_description_only(self): - """Updates only the description field.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result()) - - service = WebhookService(ap) - - # Execute - await service.update_webhook(WORKSPACE_UUID, 1, description='Updated description') - - # Verify - ap.persistence_mgr.execute_async.assert_called_once() - - async def test_update_webhook_enabled_only(self): - """Updates only the enabled field.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result()) - - service = WebhookService(ap) - - # Execute - await service.update_webhook(WORKSPACE_UUID, 1, enabled=False) - - # Verify - ap.persistence_mgr.execute_async.assert_called_once() - - async def test_update_webhook_all_fields(self): - """Updates all fields at once.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result()) - - service = WebhookService(ap) - - # Execute - await service.update_webhook( - WORKSPACE_UUID, - 1, - name='All Updated', - url='http://all.updated.com', - description='All updated description', + async def test_update_webhook_no_fields(self, tenant_webhook_service): + """An empty update reports scoped existence and leaves persisted values unchanged.""" + created = await tenant_webhook_service.create_webhook( + ISOLATION_WORKSPACE_A, + 'Unchanged', + 'https://unchanged.example.com', + description='Keep this description', enabled=False, ) - # Verify - ap.persistence_mgr.execute_async.assert_called_once() - - async def test_update_webhook_no_fields(self): - """Does nothing when no fields provided.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - existing = _create_mock_webhook(webhook_id=1) - ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing)) - ap.persistence_mgr.serialize_model = Mock(return_value={'id': 1}) - - service = WebhookService(ap) - - # Execute - no update parameters - await service.update_webhook(WORKSPACE_UUID, 1) - - # No write is issued; one scoped existence lookup is performed. - ap.persistence_mgr.execute_async.assert_called_once() + assert await tenant_webhook_service.update_webhook(ISOLATION_WORKSPACE_A, created['id']) is True + assert ( + await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id'], include_secret=True) + == created + ) + assert await tenant_webhook_service.update_webhook(ISOLATION_WORKSPACE_B, created['id']) is False class TestWebhookServiceDeleteWebhook: @@ -493,7 +489,8 @@ class TestWebhookServiceDeleteWebhook: await service.delete_webhook(WORKSPACE_UUID, 1) # Verify - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + _assert_webhook_write(ap, 'delete', 1) async def test_delete_webhook_nonexistent_id(self): """Delete operation completes even for nonexistent ID.""" @@ -508,7 +505,8 @@ class TestWebhookServiceDeleteWebhook: await service.delete_webhook(WORKSPACE_UUID, 999) # Verify - still called - ap.persistence_mgr.execute_async.assert_called_once() + ap.persistence_mgr.execute_async.assert_awaited_once() + _assert_webhook_write(ap, 'delete', 999) class TestWebhookServiceGetEnabledWebhooks: @@ -531,54 +529,6 @@ class TestWebhookServiceGetEnabledWebhooks: # Verify assert result == [] - async def test_get_enabled_webhooks_filters_enabled(self): - """Returns only enabled webhooks.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - # All returned webhooks should be enabled (SQL filter) - webhook1 = _create_mock_webhook(webhook_id=1, name='Enabled 1', enabled=True) - webhook2 = _create_mock_webhook(webhook_id=2, name='Enabled 2', enabled=True) - - mock_result = _create_mock_result([webhook1, webhook2]) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock( - side_effect=lambda model_cls, entity: { - 'id': entity.id, - 'name': entity.name, - 'enabled': entity.enabled, - } - ) - - service = WebhookService(ap) - - # Execute - result = await service.get_enabled_webhooks(WORKSPACE_UUID) - - # Verify - assert len(result) == 2 - assert all(w['enabled'] for w in result) - - async def test_get_enabled_webhooks_filters_disabled(self): - """Does not return disabled webhooks.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - # Empty result because query filters on enabled=True - mock_result = _create_mock_result([]) - ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result) - ap.persistence_mgr.serialize_model = Mock(return_value={}) - - service = WebhookService(ap) - - # Execute - result = await service.get_enabled_webhooks(WORKSPACE_UUID) - - # Verify - should be empty (SQL would filter disabled) - assert result == [] - ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a' ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b' @@ -684,6 +634,11 @@ async def test_cross_workspace_id_guessing_is_not_found(tenant_webhook_service): async def test_update_and_delete_are_scoped(tenant_webhook_service): + enabled = await tenant_webhook_service.create_webhook( + ISOLATION_WORKSPACE_A, + 'keep enabled', + 'https://a.invalid/enabled', + ) created = await tenant_webhook_service.create_webhook( ISOLATION_WORKSPACE_A, 'old', @@ -695,7 +650,13 @@ async def test_update_and_delete_are_scoped(tenant_webhook_service): name='new', enabled=False, ) - assert await tenant_webhook_service.get_enabled_webhooks(ISOLATION_WORKSPACE_A) == [] + updated = await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id'], include_secret=True) + assert updated['name'] == 'new' + assert updated['enabled'] is False + assert updated['url'] == 'https://a.invalid/old' + assert [item['id'] for item in await tenant_webhook_service.get_enabled_webhooks(ISOLATION_WORKSPACE_A)] == [ + enabled['id'] + ] assert await tenant_webhook_service.delete_webhook(ISOLATION_WORKSPACE_A, created['id']) assert await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id']) is None diff --git a/tests/unit_tests/api/service/test_wizard_regressions.py b/tests/unit_tests/api/service/test_wizard_regressions.py new file mode 100644 index 000000000..e79ac43ef --- /dev/null +++ b/tests/unit_tests/api/service/test_wizard_regressions.py @@ -0,0 +1,20 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy + +from langbot.pkg.entity.persistence.metadata import Metadata, WorkspaceMetadata +from langbot.pkg.api.http.service.bot import BotService + + +def test_metadata_supports_long_wizard_json(): + for model in (Metadata, WorkspaceMetadata): + assert isinstance(model.__table__.c.value.type, sqlalchemy.Text) + + +@pytest.mark.asyncio +async def test_saved_bot_without_runtime_has_empty_logs(): + service = BotService(SimpleNamespace(platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)))) + service.get_bot = AsyncMock(return_value={'uuid': 'bot'}) + assert await service.list_event_logs('workspace', 'bot', 0, 20) == ([], 0) diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py index 4eb28d1b4..e6bf6ca6c 100644 --- a/tests/unit_tests/api/test_adapter_session_scoping.py +++ b/tests/unit_tests/api/test_adapter_session_scoping.py @@ -190,10 +190,17 @@ async def test_session_capacity_evicts_oldest_session_in_same_workspace(): _bind_session_scope(session, owner_context) sessions[f'session-{index}'] = session + foreign_task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock()) + foreign = {'created_at': -1.0, 'task': foreign_task} + _bind_session_scope(foreign, _request_context(workspace_uuid='workspace-b')) + sessions['foreign'] = foreign + _make_room_for_session(sessions, owner_context) assert 'session-0' not in sessions - assert len(sessions) == 9 + assert len(sessions) == 10 + assert sessions['foreign'] is foreign + foreign_task.cancel.assert_not_called() tasks[0].cancel.assert_called_once_with() diff --git a/tests/unit_tests/api/test_apikey_service.py b/tests/unit_tests/api/test_apikey_service.py index c4fc4c12c..34390dcac 100644 --- a/tests/unit_tests/api/test_apikey_service.py +++ b/tests/unit_tests/api/test_apikey_service.py @@ -3,10 +3,12 @@ from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, Mock +import hashlib +import sqlalchemy import pytest from langbot.pkg.api.http.service.apikey import ApiKeyService -from langbot.pkg.entity.persistence.apikey import ApiKeyStatus +from langbot.pkg.entity.persistence.apikey import ApiKey, ApiKeyStatus @pytest.mark.asyncio @@ -76,6 +78,27 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists): result = await service.verify_api_key('lbk_valid_format') assert result is key_exists + digest = hashlib.sha256(b'lbk_valid_format').hexdigest() + statements = [call.args[0] for call in persistence_mgr.execute_async.await_args_list] + assert statements[0].whereclause.compare(ApiKey.key_hash == digest) + if key_exists: + assert statements[1].whereclause.compare( + sqlalchemy.and_( + ApiKey.id == 1, + ApiKey.workspace_uuid == 'workspace-a', + ApiKey.key_hash == digest, + ) + ) + assert statements[2].is_update + assert statements[2].whereclause.compare( + sqlalchemy.and_( + ApiKey.id == 1, + ApiKey.workspace_uuid == 'workspace-a', + ApiKey.key_hash == digest, + ApiKey.status == ApiKeyStatus.ACTIVE.value, + ) + ) + assert statements[2].compile().params['last_used_at'] is not None assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1) if key_exists: workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a') diff --git a/tests/unit_tests/api/test_bots_controller_errors.py b/tests/unit_tests/api/test_bots_controller_errors.py index 12920a83b..1d8730c61 100644 --- a/tests/unit_tests/api/test_bots_controller_errors.py +++ b/tests/unit_tests/api/test_bots_controller_errors.py @@ -106,6 +106,7 @@ async def test_runtime_error_redacts_persisted_secrets_on_partial_update(): ap = SimpleNamespace( persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1))), + sess_mgr=SimpleNamespace(session_list=[]), platform_mgr=SimpleNamespace( remove_bot=AsyncMock(), load_bot=AsyncMock(side_effect=ValueError('Invalid app_secret: persisted-secret-value')), @@ -113,7 +114,12 @@ async def test_runtime_error_redacts_persisted_secrets_on_partial_update(): ) service = BotService(ap) service.get_bot = AsyncMock( - return_value={'uuid': 'bot-1', 'adapter_config': {'app_secret': 'persisted-secret-value'}} + return_value={ + 'uuid': 'bot-1', + 'adapter': 'aiocqhttp', + 'enable': True, + 'adapter_config': {'app_secret': 'persisted-secret-value'}, + } ) with pytest.raises(BotApplyError) as captured: await service.update_bot('workspace-test', 'bot-1', {'enable': True}) diff --git a/tests/unit_tests/api/test_extensions_runtime_fence.py b/tests/unit_tests/api/test_extensions_runtime_fence.py index 12f36917e..02bff861f 100644 --- a/tests/unit_tests/api/test_extensions_runtime_fence.py +++ b/tests/unit_tests/api/test_extensions_runtime_fence.py @@ -99,7 +99,11 @@ async def test_extensions_route_redacts_plugin_secrets_without_mutating_runtime_ @pytest.mark.asyncio async def test_extensions_parallel_reads_open_explicit_child_task_scopes(): + from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as connection: + await connection.run_sync(WorkspaceMetadata.__table__.create) account = SimpleNamespace(uuid='account-a', user='owner@example.com') ap = SimpleNamespace() ap.persistence_mgr = PersistenceManager(ap) diff --git a/tests/unit_tests/api/test_file_upload_scoping.py b/tests/unit_tests/api/test_file_upload_scoping.py index 0463a4c84..7d13f92dc 100644 --- a/tests/unit_tests/api/test_file_upload_scoping.py +++ b/tests/unit_tests/api/test_file_upload_scoping.py @@ -9,6 +9,7 @@ import quart from quart.datastructures import FileStorage from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup +from langbot.pkg.api.http.context import RequestContext pytestmark = pytest.mark.asyncio @@ -52,6 +53,13 @@ async def test_document_upload_uses_dedicated_scoped_owner_type(): assert response.status_code == 200 assert (await response.get_json())['data']['file_id'] == 'scoped-document-key' + storage_mgr.save_scoped.assert_awaited_once() + context = storage_mgr.save_scoped.await_args.args[0] + assert isinstance(context, RequestContext) + assert context.instance_uuid == 'instance-test' + assert context.workspace_uuid == '00000000-0000-0000-0000-00000000000a' + assert context.placement_generation == 3 + assert context.account_uuid == 'account-test' kwargs = storage_mgr.save_scoped.await_args.kwargs assert kwargs['owner_type'] == 'upload_document' assert kwargs['owner'] == 'account:account-test' diff --git a/tests/unit_tests/api/test_management_assistant.py b/tests/unit_tests/api/test_management_assistant.py index a3f993e6b..8e6fc78b7 100644 --- a/tests/unit_tests/api/test_management_assistant.py +++ b/tests/unit_tests/api/test_management_assistant.py @@ -74,7 +74,7 @@ async def assistant(tmp_path): ap = SimpleNamespace( persistence_mgr=SimpleNamespace(execute_async=execute), logger=logging.getLogger('assistant-test'), - space_service=SimpleNamespace(get_recommended_chat_model=AsyncMock(return_value={'uuid': 'model'})), + space_service=SimpleNamespace(get_recommended_assistant_model=AsyncMock(return_value={'uuid': 'model'})), model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), pipeline_service=SimpleNamespace(create_pipeline=AsyncMock(return_value='created-pipeline')), ) @@ -234,3 +234,250 @@ async def test_model_switch_preserves_history_and_rejects_invalid_selection(assi ap.model_mgr.get_model_by_uuid.assert_awaited_with( next_provider.invoke_llm.call_args.kwargs['execution_context'], 'second' ) + + +@pytest.mark.asyncio +async def test_recommendation_resolved_before_any_turn(assistant): + service, ap, provider = assistant + assert await service.recommended_model(context()) == {'uuid': 'model', 'name': 'test-model'} + provider.invoke_llm.assert_not_awaited() + model = ap.model_mgr.get_model_by_uuid.return_value + model.model_entity.abilities = [] + with pytest.raises(AssistantError, match='model_unavailable'): + await service.recommended_model(context()) + + +@pytest.mark.asyncio +async def test_stream_selected_model_deltas_tools_and_approval(assistant): + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + import copy + + service, ap, old_provider = assistant + events = [] + inputs = [] + + async def stream(**kwargs): + inputs.append(kwargs) + if len(inputs) == 1: + yield MessageChunk(role='assistant', content='Create ') + yield MessageChunk(role='assistant', content='this?') + call = proposal().tool_calls[0].model_dump() + call['function']['arguments'] = '{"name":"Demo",' + yield MessageChunk(role='assistant', tool_calls=[call]) + call['function']['arguments'] = '"description":"Test draft"}' + yield MessageChunk( + role='assistant', tool_calls=[call], is_final=True, provider_specific_fields={'signature': 'kept'} + ) + else: + yield MessageChunk(role='assistant', content='Done') + yield MessageChunk(role='assistant', content='', all_content='Done', is_final=True) + + chosen = SimpleNamespace( + provider=SimpleNamespace(invoke_llm_stream=stream), + model_entity=SimpleNamespace(name='manual-model', abilities=['func_call'], extra_args={}), + ) + ap.model_mgr.get_model_by_uuid.return_value = chosen + + async def emit(event): + events.append(copy.deepcopy(event)) + + ctx = context() + cid = (await service.create(ctx))['uuid'] + pending = await service.turn(ctx, cid, 0, text='Create', model_uuid='manual', on_event=emit) + assert pending['status'] == 'approval' + assert pending['model_uuid'] == 'manual' + assert inputs[0]['model'] is chosen + ap.space_service.get_recommended_assistant_model.assert_not_awaited() + old_provider.invoke_llm.assert_not_awaited() + assert [e['data']['text'] for e in events if e['kind'] == 'text'] == ['Create ', 'Create this?'] + ap.pipeline_service.create_pipeline.assert_not_awaited() + events.clear() + complete = await service.turn(ctx, cid, 1, approved=True, on_event=emit) + assert complete['status'] == 'ready' + assert complete['messages'][-1]['content'] == 'Done' + ap.pipeline_service.create_pipeline.assert_awaited_once() + assert inputs[1]['messages'][2].provider_specific_fields == {'signature': 'kept'} + tool_start = next(i for i, e in enumerate(events) if e['kind'] == 'phase' and e['data']['phase'] == 'tool') + result = next( + i + for i, e in enumerate(events) + if e['kind'] == 'snapshot' and any(m['role'] == 'tool' for m in e['data']['messages']) + ) + assert tool_start < result + with pytest.raises(AssistantError, match='stale_turn'): + await service.turn(ctx, cid, 1, approved=True, on_event=emit) + + +@pytest.mark.asyncio +async def test_truncated_stream_never_executes_proposed_write(assistant): + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + + service, ap, provider = assistant + + async def stream(**kwargs): + yield MessageChunk(role='assistant', tool_calls=proposal().tool_calls) + + provider.invoke_llm_stream = stream + cid = (await service.create(context()))['uuid'] + result = await service.turn(context(), cid, 0, text='Create', on_event=AsyncMock()) + assert result['status'] == 'failed' + assert result['error'] == 'stream_interrupted' + ap.pipeline_service.create_pipeline.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_stream_persists_non_replayable_state(assistant): + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + + service, ap, provider = assistant + started = asyncio.Event() + + async def stream(**kwargs): + yield MessageChunk(role='assistant', content='Hello') + started.set() + await asyncio.Event().wait() + + provider.invoke_llm_stream = stream + ctx = context() + cid = (await service.create(ctx))['uuid'] + task = asyncio.create_task(service.turn(ctx, cid, 0, text='Hello', on_event=AsyncMock())) + await asyncio.wait_for(started.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + saved = await service.get(ctx, cid) + assert saved['status'] == 'failed' + assert saved['error'] == 'result_unknown' + with pytest.raises(AssistantError, match='stale_turn'): + await service.turn(ctx, cid, 0, text='Hello') + + +@pytest.mark.asyncio +async def test_http_stream_emits_before_provider_completion_and_restores_tenant(assistant): + import contextlib + import json + import quart + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput + + service, ap, provider = assistant + released = asyncio.Event() + scoped = [] + + @contextlib.asynccontextmanager + async def tenant_scope(workspace): + scoped.append(workspace) + try: + yield + finally: + scoped.pop() + + async def stream(**kwargs): + assert scoped == ['workspace-a'] + yield MessageChunk(role='assistant', content='First') + await released.wait() + yield MessageChunk(role='assistant', content=' second', is_final=True) + + ap.persistence_mgr.tenant_scope = tenant_scope + provider.invoke_llm_stream = stream + ctx = context() + cid = (await service.create(ctx))['uuid'] + app = quart.Quart(__name__) + + @app.post('/stream') + async def route(): + return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi')) + + frames = [] + async with app.test_client().request('/stream', method='POST') as connection: + while not any(frame['kind'] == 'text' for frame in frames): + chunk = await asyncio.wait_for(connection.receive(), 5) + frames.extend(json.loads(line) for line in chunk.decode().splitlines() if line.strip()) + assert frames[-1]['data']['text'] == 'First' + assert not released.is_set() + released.set() + while not any(frame['kind'] == 'completed' for frame in frames): + chunk = await asyncio.wait_for(connection.receive(), 5) + frames.extend(json.loads(line) for line in chunk.decode().splitlines() if line.strip()) + assert frames[-1]['data']['messages'][-1]['content'] == 'First second' + assert scoped == [] + + +@pytest.mark.asyncio +async def test_background_run_survives_http_disconnect_and_finishes(assistant): + import json + import quart + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + from langbot.pkg.api.http.controller.groups.assistant import assistant_stream_response, TurnInput + + service, ap, provider = assistant + released = asyncio.Event() + + async def stream(**kwargs): + yield MessageChunk(role='assistant', content='Background') + await released.wait() + yield MessageChunk(role='assistant', content=' done', is_final=True) + + provider.invoke_llm_stream = stream + ctx = context() + cid = (await service.create(ctx))['uuid'] + app = quart.Quart(__name__) + + @app.post('/stream') + async def route(): + return assistant_stream_response(service, ctx, cid, TurnInput(revision=0, text='Hi')) + + async with app.test_client().request('/stream', method='POST') as connection: + while True: + chunk = await asyncio.wait_for(connection.receive(), 5) + if any(json.loads(line)['kind'] == 'text' for line in chunk.decode().splitlines() if line.strip()): + break + task = service._runs[cid]['task'] + await connection.disconnect() + assert not task.done() + assert (await service.progress(ctx, cid))['progress']['text'] == 'Background' + released.set() + await asyncio.wait_for(task, 5) + completed = await service.get(ctx, cid) + assert completed['status'] == 'ready' + assert completed['messages'][-1]['content'] == 'Background done' + + +@pytest.mark.asyncio +async def test_stop_is_private_revision_scoped_and_does_not_stop_other_chat(assistant): + from langbot_plugin.api.entities.builtin.provider.message import MessageChunk + from langbot.pkg.api.http.controller.groups.assistant import TurnInput + + service, ap, provider = assistant + released = asyncio.Event() + + async def stream(**kwargs): + yield MessageChunk(role='assistant', content='Partial') + await released.wait() + yield MessageChunk(role='assistant', content=' done', is_final=True) + + provider.invoke_llm_stream = stream + ctx = context() + ids = [(await service.create(ctx))['uuid'] for _ in range(2)] + tasks = [] + for cid in ids: + queue = service.start_run(ctx, cid, TurnInput(revision=0, text='Background task')) + tasks.append(service._runs[cid]['task']) + while (await asyncio.wait_for(queue.get(), 5))['kind'] != 'text': + pass + for other in (context(account='bob'), context(workspace='workspace-b')): + assert await service.list_conversations(other) == [] + with pytest.raises(AssistantError, match='conversation_not_found'): + await service.stop(other, ids[0], 1) + with pytest.raises(AssistantError, match='stale_turn'): + await service.stop(ctx, ids[0], 0) + result = await service.stop(ctx, ids[0], 1) + assert result['status'] == 'failed' + assert result['error'] == 'stopped' + assert result['messages'][-1]['content'] == 'Partial' + assert not tasks[1].done() + assert len(await service.list_conversations(ctx)) == 2 + assert (await service.stop(ctx, ids[0], 1))['error'] == 'stopped' + released.set() + await asyncio.wait_for(tasks[1], 5) + assert (await service.get(ctx, ids[1]))['status'] == 'ready' diff --git a/tests/unit_tests/api/test_mcp_server.py b/tests/unit_tests/api/test_mcp_server.py index 228b23ffa..3615f8286 100644 --- a/tests/unit_tests/api/test_mcp_server.py +++ b/tests/unit_tests/api/test_mcp_server.py @@ -65,3 +65,15 @@ async def test_mcp_server_exposes_bot_event_route_tools(): assert 'test_bot_event_route' not in tool_names assert 'list_processors' in tool_names assert 'list_agents' not in tool_names + assert 'get_workspace_message_language' in tool_names + language_tool = next(tool for tool in tools if tool.name == 'set_workspace_message_language') + assert set(language_tool.inputSchema['properties']['language']['enum']) == { + 'en_US', + 'zh_Hans', + 'zh_Hant', + 'ja_JP', + 'es_ES', + 'ru_RU', + 'th_TH', + 'vi_VN', + } diff --git a/tests/unit_tests/api/test_pipeline_service_defaults.py b/tests/unit_tests/api/test_pipeline_service_defaults.py index dd7687d87..20fa685ac 100644 --- a/tests/unit_tests/api/test_pipeline_service_defaults.py +++ b/tests/unit_tests/api/test_pipeline_service_defaults.py @@ -8,6 +8,7 @@ import pytest from langbot.pkg.agent.runner.descriptor import RunnerDescriptor from langbot.pkg.api.http.service.pipeline import PipelineService +from langbot.pkg.api.http.service.model import LLMModelsService class FakeLogger: @@ -54,6 +55,7 @@ async def test_default_pipeline_config_uses_first_installed_runner_schema(): logger=FakeLogger(), runner_registry=FakeRegistry([custom_agent, local_agent]), ) + ap.llm_model_service = LLMModelsService(ap) config = await PipelineService(ap).get_default_pipeline_config('workspace-test') diff --git a/tests/unit_tests/api/test_provider_controller_secrets.py b/tests/unit_tests/api/test_provider_controller_secrets.py index 78c72ba8e..73d9fe990 100644 --- a/tests/unit_tests/api/test_provider_controller_secrets.py +++ b/tests/unit_tests/api/test_provider_controller_secrets.py @@ -62,9 +62,7 @@ async def _create_client(role: str): application.provider_service = SimpleNamespace( get_providers=AsyncMock(side_effect=get_providers), get_provider=AsyncMock(side_effect=get_provider), - get_provider_model_counts=AsyncMock( - return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1} - ), + get_provider_model_counts=AsyncMock(return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}), ) def model_service(list_name: str, get_name: str): @@ -136,8 +134,15 @@ async def test_default_preserves_secrets_and_explicit_false_redacts_high_permiss false_data = await false_response.get_json() default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0] false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0] - assert '***' not in str(default_value) + expected = ( + {**RAW_PROVIDER, 'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1} + if endpoint.endswith('/providers') + else RAW_MODEL + ) + assert default_value == expected assert '***' in str(false_value) + assert 'provider-secret' not in str(false_data) + assert 'model-secret' not in str(false_data) @pytest.mark.parametrize( @@ -158,6 +163,8 @@ async def test_explicit_true_does_not_grant_low_permission_reads(endpoint): data = await response.get_json() value = data['data'].get('providers', data['data'].get('models'))[0] assert '***' in str(value) + assert 'provider-secret' not in str(data) + assert 'model-secret' not in str(data) @pytest.mark.parametrize( diff --git a/tests/unit_tests/api/test_slack_setup.py b/tests/unit_tests/api/test_slack_setup.py new file mode 100644 index 000000000..2a25effd6 --- /dev/null +++ b/tests/unit_tests/api/test_slack_setup.py @@ -0,0 +1,153 @@ +import asyncio +import hashlib +import hmac +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service.slack_setup import SlackSetupService, SlackSetupError, callback_url + + +def context(workspace='one', principal='user'): + return SimpleNamespace(instance_uuid='local', workspace_uuid=workspace, placement_generation=1, principal=principal) + + +def service(): + ap = SimpleNamespace( + bot_service=SimpleNamespace(get_bot=AsyncMock(return_value={'adapter': 'slack-omni'})), + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()), + task_mgr=SimpleNamespace( + create_user_task=lambda coro, **kwargs: SimpleNamespace(task=asyncio.create_task(coro)) + ), + ) + obj = SlackSetupService(ap) + obj.api = AsyncMock( + side_effect=[ + {'app_id': 'A123', 'credentials': {'client_id': 'id', 'client_secret': 'secret', 'signing_secret': 'sign'}}, + {'ok': True}, + {'app_id': 'A123', 'token_type': 'bot', 'access_token': 'bot-token'}, + ] + ) + return obj + + +async def start(obj, **kwargs): + result = await obj.start( + context(), + bot_uuid='bot', + access_token='access', + refresh_token='refresh', + webhook_url='https://example.com/bots/bot', + **kwargs, + ) + sid = result['session_id'] + await obj.sessions[sid]['task'] + return sid + + +@pytest.mark.asyncio +async def test_webhook_setup_oauth_and_scope(): + obj = service() + sid = await start(obj) + assert obj.status(context(), sid)['status'] == 'waiting' + assert 'state=' + sid in obj.status(context(), sid)['authorize_url'] + for ctx in (context('two'), context(principal='other')): + with pytest.raises(SlackSetupError): + obj.status(ctx, sid) + calls = obj.api.await_args_list + assert 'event_subscriptions' not in json.loads(calls[0].kwargs['manifest'])['settings'] + assert json.loads(calls[1].kwargs['manifest'])['settings']['event_subscriptions']['request_url'].endswith( + '/bots/bot' + ) + await obj.finish(sid, 'code') + assert obj.status(context(), sid)['config']['bot_token'] == 'bot-token' + assert 'credentials' not in obj.sessions[sid] + with pytest.raises(SlackSetupError): + await obj.finish(sid, 'replay') + obj.discard(sid) + assert not obj.sessions + + +@pytest.mark.asyncio +async def test_socket_manifest_needs_no_public_webhook(): + obj = service() + sid = await start( + obj, socket_mode=True, redirect_url='http://localhost:5300/api/v1/platform/adapters/slack/setup/callback' + ) + config = json.loads(obj.api.await_args_list[1].kwargs['manifest']) + assert config['settings']['socket_mode_enabled'] + assert 'request_url' not in config['settings']['event_subscriptions'] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_expired_token_rotates_once(): + obj = service() + obj.api.side_effect = [ + SlackSetupError('token_expired'), + {'token': 'new-access', 'refresh_token': 'new-refresh'}, + {'app_id': 'A123', 'credentials': {'client_id': 'id', 'client_secret': 'secret', 'signing_secret': 'sign'}}, + {'ok': True}, + ] + sid = await start(obj) + assert obj.api.await_args_list[1].args == ('tooling.tokens.rotate',) + assert obj.api.await_args_list[2].args == ('apps.manifest.create', 'new-access') + assert 'refresh' not in obj.sessions[sid] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_provisioning_error_is_visible_without_secrets(): + obj = service() + obj.api.side_effect = RuntimeError('sensitive upstream text') + sid = await start(obj) + assert obj.status(context(), sid) == {'status': 'error', 'error': 'setup_failed'} + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_denied_authorization(): + obj = service() + sid = await start(obj) + with pytest.raises(SlackSetupError): + await obj.finish(sid, '', 'access_denied') + assert obj.status(context(), sid)['error'] == 'authorization_denied' + assert 'credentials' not in obj.sessions[sid] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_setup_webhook_only_accepts_signed_verification(): + obj = service() + sid = await start(obj) + body = json.dumps({'type': 'url_verification', 'challenge': 'test'}).encode() + timestamp = str(int(time.time())) + signature = 'v0=' + hmac.new(b'sign', b'v0:' + timestamp.encode() + b':' + body, hashlib.sha256).hexdigest() + req = SimpleNamespace( + get_data=AsyncMock(return_value=body), + get_json=AsyncMock(return_value=json.loads(body)), + headers={'X-Slack-Request-Timestamp': timestamp, 'X-Slack-Signature': signature}, + ) + assert await obj.challenge('bot', req) == {'challenge': 'test'} + req.headers['X-Slack-Signature'] = 'forged' + assert await obj.challenge('bot', req) is None + obj.discard(sid) + + +@pytest.mark.parametrize( + 'url', + [ + 'http://example.com/bots/bot', + 'https://example.com/bots/other', + 'https://user:pass@example.com/bots/bot', + 'https://localhost/bots/bot', + 'https://127.0.0.1/bots/bot', + 'https://example.com/bots/bot?key=secret', + ], +) +def test_invalid_callback_url(url): + with pytest.raises(SlackSetupError): + callback_url(url, 'bot') diff --git a/tests/unit_tests/api/test_user_reset_password.py b/tests/unit_tests/api/test_user_reset_password.py index 4ae70d90d..68ae5cfd0 100644 --- a/tests/unit_tests/api/test_user_reset_password.py +++ b/tests/unit_tests/api/test_user_reset_password.py @@ -16,7 +16,7 @@ import asyncio import logging import time from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, call import pytest import quart @@ -65,13 +65,17 @@ def _make_genkeys_ap(existing_key: str) -> SimpleNamespace: ) -async def test_recovery_key_generation_is_short_and_unambiguous(): +async def test_recovery_key_generation_is_short_and_unambiguous(monkeypatch): """Eight random base32 characters balance manual entry and online throttling.""" ap = _make_genkeys_ap(existing_key='') + choice = Mock(side_effect=list('ZYXW9876')) + monkeypatch.setattr('langbot.pkg.core.stages.genkeys.secrets.choice', choice) await GenKeysStage().run(ap) key = ap.instance_config.data['system']['recovery_key'] + assert key == 'ZYXW9876' + assert choice.call_args_list == [call('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')] * 8 assert len(key) == 8 assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ') assert ap.instance_config.dump_config.called @@ -198,7 +202,7 @@ async def test_non_string_recovery_key_does_not_crash(): @pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff']) async def test_non_ascii_recovery_key_does_not_crash(key): """Non-ASCII keys must compare safely (encode-based constant-time compare).""" - client, _, _ = await _create_client() + client, reset_password, _ = await _create_client() resp = await client.post( '/api/v1/user/reset-password', @@ -206,6 +210,7 @@ async def test_non_ascii_recovery_key_does_not_crash(key): ) assert resp.status_code == 403 + reset_password.assert_not_awaited() async def test_quota_exhausted_after_max_attempts(): diff --git a/tests/unit_tests/box/test_attachment_host_capabilities.py b/tests/unit_tests/box/test_attachment_host_capabilities.py new file mode 100644 index 000000000..27aabc7de --- /dev/null +++ b/tests/unit_tests/box/test_attachment_host_capabilities.py @@ -0,0 +1,142 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.box.service import BoxService +from langbot_plugin.box.client import BoxRuntimeClient +from tests.unit_tests.box.test_box_service import make_app, make_query + + +@pytest.mark.asyncio +async def test_host_without_openat_uses_sandbox_attachment_channel(monkeypatch, tmp_path): + monkeypatch.setattr('os.supports_dir_fd', set()) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service._available = True + service._tenant_workspace = Mock(return_value=str(tmp_path)) + service._read_outbox_host = Mock(side_effect=AssertionError('POSIX host reader must not run')) + service._read_outbox_via_exec = AsyncMock(return_value=[]) + service._clear_outbox = AsyncMock() + query = make_query() + assert service._host_query_dir(service.OUTBOX_SUBDIR, query) is None + assert await service.collect_outbound_attachments(query) == [] + service._read_outbox_via_exec.assert_awaited_once_with(query) + service._clear_outbox.assert_awaited_once_with(query, None) + + +@pytest.mark.asyncio +async def test_outbox_exec_preserves_workspace_and_full_attachment(monkeypatch, tmp_path): + import base64 + import json + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + client = Mock(spec=BoxRuntimeClient) + content = base64.b64encode(b'file content' * 1000).decode() + client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='docker', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout=json.dumps([{'name': 'report.txt', 'b64': content}]), + stderr='', + duration_ms=1, + ) + ) + service = BoxService(make_app(Mock(), [str(tmp_path)]), client=client, output_limit_chars=100) + service._available = True + service._tenant_workspace = Mock(return_value=str(tmp_path)) + service._clear_outbox = AsyncMock() + query = make_query() + attachments = await service.collect_outbound_attachments(query) + assert attachments == [{'type': 'File', 'name': 'report.txt', 'base64': content}] + call = client.execute.await_args + assert call.kwargs['action_context'].workspace_uuid == query.workspace_uuid + assert call.kwargs['action_context'].instance_uuid == query.instance_uuid + assert call.args[0].host_path == str(tmp_path) + assert call.args[0].session_id == 'person_test_user' + service._clear_outbox.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['rpc', 'command', 'invalid_json']) +async def test_outbox_read_failure_keeps_files(monkeypatch, failure): + from langbot_plugin.box.errors import BoxValidationError + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + client = Mock(spec=BoxRuntimeClient) + client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='docker', + status=BoxExecutionStatus.COMPLETED, + exit_code=1 if failure == 'command' else 0, + stdout='not json', + stderr='', + duration_ms=1, + ) + ) + if failure == 'rpc': + client.execute.side_effect = BoxValidationError('missing context') + service = BoxService(make_app(Mock()), client=client) + service._available = True + service._clear_outbox = AsyncMock() + with pytest.raises(BoxValidationError): + await service.collect_outbound_attachments(make_query()) + service._clear_outbox.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('oversized', [False, True]) +async def test_real_outbox_reader_handles_image_above_old_256k_limit(tmp_path, monkeypatch, oversized): + import asyncio + import base64 + import sys + from langbot_plugin.box.errors import BoxValidationError + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service._available = True + service.OUTBOX_MOUNT_DIR = tmp_path.as_posix() + if oversized: + service._ATTACHMENT_MAX_BYTES = 300000 + folder = tmp_path / 'query-42' + folder.mkdir() + payload = b'\x89PNG\r\n\x1a\n' + b'x' * (343154 - 8) + (folder / 'sunset.png').write_bytes(payload) + + async def execute_script(spec, query): + script = spec['cmd'].split('\n', 1)[1].rsplit('\nLBPY', 1)[0] + process = await asyncio.create_subprocess_exec( + sys.executable, + '-c', + script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + return BoxExecutionResult( + session_id='person_test_user', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=process.returncode, + stdout=stdout.decode(), + stderr=stderr.decode(), + duration_ms=1, + ) + + service._execute_spec_payload_result = execute_script + service._clear_outbox = AsyncMock() + if oversized: + with pytest.raises(BoxValidationError): + await service.collect_outbound_attachments(make_query()) + service._clear_outbox.assert_not_awaited() + assert (folder / 'sunset.png').exists() + else: + attachments = await service.collect_outbound_attachments(make_query()) + assert len(attachments) == 1 + assert attachments[0]['type'] == 'Image' + assert base64.b64decode(attachments[0]['base64'].split(',', 1)[1]) == payload + service._clear_outbox.assert_awaited_once() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 655eba7e1..aa7471411 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -42,6 +42,7 @@ from langbot_plugin.entities.io.context import ActionContext from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.box.service import BoxService from langbot.pkg.box.runner import RunBoxBinding +from langbot.pkg.provider.tools.loaders import skill as skill_loader _UTC = dt.timezone.utc _CONTEXT = ExecutionContext( @@ -307,7 +308,7 @@ class TestSharesFilesystemWithBox: - 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. + This drives whether LangBot can safely perform local workspace operations. Getting it wrong silently drops every skill in separated deployments. """ @@ -344,7 +345,7 @@ class TestSharesFilesystemWithBox: 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. + # so LangBot does not assume a shared local filesystem. service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) assert service._runtime_connector is None @@ -433,7 +434,7 @@ async def test_box_service_get_sessions_delegates_to_client(): sessions = await service.get_sessions(_CONTEXT) assert sessions == [{'session_id': 'test-session'}] - client.get_sessions.assert_awaited_once() + client.get_sessions.assert_awaited_once_with(action_context=_ACTION_CONTEXT) @pytest.mark.asyncio @@ -572,7 +573,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup( monkeypatch: pytest.MonkeyPatch, ): app = make_app(Mock()) - app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock()) service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) connector = Mock() connector.reconnect = AsyncMock() @@ -585,29 +585,28 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup( connector.reconnect.assert_awaited_once() service._ensure_default_workspace.assert_called_once() service._purge_attachment_dirs.assert_awaited_once() - app.skill_mgr.reload_skills.assert_awaited_once_with(_CONTEXT) assert service.available is True @pytest.mark.asyncio -async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills( +async def test_cloud_box_service_reconnect_restores_runtime_only( monkeypatch: pytest.MonkeyPatch, ): app = make_app(Mock()) - app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock()) service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) service._cloud_managed = True connector = Mock() connector.reconnect = AsyncMock() service._ensure_default_workspace = Mock() service._verify_cloud_runtime = AsyncMock() + service._purge_attachment_dirs = AsyncMock() monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock()) await service._reconnect_loop(connector) connector.reconnect.assert_awaited_once() service._verify_cloud_runtime.assert_awaited_once() - app.skill_mgr.reload_skills.assert_not_awaited() + service._purge_attachment_dirs.assert_not_awaited() assert service.available is True @@ -677,6 +676,8 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path): assert result['ok'] is True assert backend.start_calls == ['person_test_user'] + assert backend.start_specs[0].host_path == os.path.realpath(host_dir) + assert backend.start_specs[0].host_path_mode == BoxHostMountMode.READ_WRITE @pytest.mark.asyncio @@ -747,16 +748,20 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): service = BoxService(make_app(logger, [str(allowed_root)]), client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - with pytest.raises(BoxValidationError): + with pytest.raises(BoxValidationError, match='outside allowed_mount_roots'): await service.execute_spec_payload( { 'cmd': 'pwd', 'host_path': str(disallowed_root), - 'session_id': '12', + 'session_id': 'person_test_user', }, make_query(12), ) + assert backend.start_calls == [] + assert backend.exec_calls == [] + assert list(disallowed_root.iterdir()) == [] + @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): @@ -934,20 +939,21 @@ async def test_truncate_stderr_independently(): @pytest.mark.asyncio -async def test_profile_default_provides_defaults(): +@pytest.mark.parametrize('profile_name,network', [('default', BoxNetworkMode.ON), ('offline', BoxNetworkMode.OFF)]) +async def test_profile_default_provides_defaults(profile_name, network): """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)) + service = BoxService(make_app(logger, profile=profile_name), 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 + profile = BUILTIN_PROFILES[profile_name] + assert spec.network == network assert spec.image == profile.image assert spec.timeout_sec == profile.timeout_sec @@ -962,14 +968,14 @@ async def test_profile_unlocked_field_can_be_overridden(): await service.initialize() result = await service.execute_spec_payload( - {'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'on', 'session_id': 'person_test_user'}, + {'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'off', 'session_id': 'person_test_user'}, make_query(31), ) assert result['ok'] is True spec = backend.start_specs[0] assert spec.timeout_sec == 60 - assert spec.network == BoxNetworkMode.ON + assert spec.network == BoxNetworkMode.OFF @pytest.mark.asyncio @@ -1037,10 +1043,15 @@ def test_unknown_profile_raises_error(): def test_builtin_profiles_are_consistent(): """Basic sanity check on all built-in profiles.""" assert 'default' in BUILTIN_PROFILES + assert 'offline' in BUILTIN_PROFILES assert 'offline_readonly' in BUILTIN_PROFILES assert 'network_basic' in BUILTIN_PROFILES assert 'network_extended' in BUILTIN_PROFILES + default = BUILTIN_PROFILES['default'] + assert default.network == BoxNetworkMode.ON + assert BUILTIN_PROFILES['offline'] == default.model_copy(update={'name': 'offline', 'network': BoxNetworkMode.OFF}) + offline = BUILTIN_PROFILES['offline_readonly'] assert offline.network == BoxNetworkMode.OFF assert offline.host_path_mode == BoxHostMountMode.READ_ONLY @@ -1061,8 +1072,18 @@ def test_builtin_profiles_are_consistent(): @pytest.mark.asyncio -async def test_profile_default_applies_resource_limits(): +async def test_profile_default_applies_resource_limits(monkeypatch): """Default profile resource limits are applied to BoxSpec.""" + profile = BUILTIN_PROFILES['default'].model_copy( + update={ + 'cpus': 0.75, + 'memory_mb': 320, + 'pids_limit': 37, + 'read_only_rootfs': False, + 'workspace_quota_mb': 19, + } + ) + monkeypatch.setitem(BUILTIN_PROFILES, 'default', profile) logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) @@ -1470,8 +1491,14 @@ async def test_rpc_client_get_status(): try: status = await client.get_status(action_context=_ACTION_CONTEXT) - assert 'backend' in status - assert 'active_sessions' in status + assert status['backend']['name'] == 'fake' + assert status['backend']['available'] is True + assert status['active_sessions'] == 0 + await client.execute(BoxSpec(cmd='true', session_id='status-session'), action_context=_ACTION_CONTEXT) + status = await client.get_status(action_context=_ACTION_CONTEXT) + assert status['active_sessions'] == 1 + assert status['managed_processes'] == 0 + assert status['session_ttl_sec'] == 300 finally: server_task.cancel() client_task.cancel() @@ -1512,8 +1539,10 @@ async def test_rpc_client_delete_session(): spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'}) await client.execute(spec, action_context=_ACTION_CONTEXT) + physical_session_id = backend.start_calls[0] await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT) + assert backend.stop_calls == [physical_session_id] sessions = await client.get_sessions(action_context=_ACTION_CONTEXT) assert len(sessions) == 0 finally: @@ -1803,99 +1832,89 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop(): assert service._reconnecting is False -class TestBuildSkillExtraMounts: - """Robustness of skill mount construction against a stale skill cache. +class TestBuildSkillExecutionMounts: + """Execution materializes only revisions pinned by this run.""" - 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): + def _make_app(self, logger, skills): app = make_app(logger) app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=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 + return app - def test_skips_skill_with_missing_package_root(self): + def test_mounts_only_activated_revision(self): logger = Mock() with tempfile.TemporaryDirectory() as live_dir: + manifest_path = os.path.join(live_dir, 'manifest.json') + with open(manifest_path, 'w', encoding='utf-8') as file: + file.write('{}') skills = { - 'alive': {'name': 'alive', 'package_root': live_dir}, - 'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'}, + 'alive': { + 'name': 'alive', + 'package_root': live_dir, + 'manifest_path': manifest_path, + 'revision': 'sha256:' + '1' * 64, + }, + 'visible-not-activated': { + 'name': 'visible-not-activated', + 'package_root': live_dir, + 'manifest_path': manifest_path, + 'revision': 'sha256:' + '2' * 64, + }, } - service = self._make_service(logger, skills) + app = self._make_app(logger, skills) query = make_query() + skill_loader.register_activated_skill(query, skills['alive']) - mounts = service.build_skill_extra_mounts(query) + mounts = skill_loader.build_execution_mounts(app, query) assert mounts == [ { 'host_path': live_dir, - 'mount_path': '/workspace/.skills/alive', - 'mode': 'rw', + 'mount_path': '/opt/langbot-artifacts/' + '1' * 64, + 'mode': 'ro', + 'content_digest': 'sha256:' + '1' * 64, + 'manifest_path': manifest_path, } ] - # 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.""" + def test_missing_pinned_revision_fails_instead_of_being_skipped(self): logger = Mock() skills = { - 'a': {'name': 'a', 'package_root': '/box/skills/a'}, - 'b': {'name': 'b', 'package_root': '/box/skills/b'}, + 'a': { + 'name': 'a', + 'package_root': '/box/skills/a', + 'manifest_path': '/box/skills/manifest.json', + 'revision': 'sha256:' + '1' * 64, + } } - service = self._make_service(logger, skills, shares_filesystem=False) + app = self._make_app(logger, skills) + query = make_query() + skill_loader.register_activated_skill(query, skills['a']) - mounts = service.build_skill_extra_mounts(make_query()) + with pytest.raises(ValueError, match='cannot be recovered safely'): + skill_loader.build_execution_mounts(app, 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): + def test_rejects_activated_skill_with_empty_package_root(self): logger = Mock() skills = { - 'no_root': {'name': 'no_root', 'package_root': ''}, - 'whitespace': {'name': 'whitespace', 'package_root': ' '}, + 'no_root': { + 'name': 'no_root', + 'package_root': '', + 'manifest_path': '', + 'revision': 'sha256:' + '1' * 64, + } } - service = self._make_service(logger, skills) + app = self._make_app(logger, skills) + query = make_query() + skill_loader.register_activated_skill(query, skills['no_root']) - 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()) == [] + with pytest.raises(ValueError, match='no immutable package root'): + skill_loader.build_execution_mounts(app, 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()) == [] + assert skill_loader.build_execution_mounts(app, make_query()) == [] # ── Attachment passthrough (inbound / outbound) ───────────────────────────── @@ -1965,13 +1984,26 @@ class TestAttachmentHelpers: class TestInboundOutboundRoundTrip: + @staticmethod + async def _execute_command(command, workspace): + process = await asyncio.create_subprocess_exec( + '/bin/sh', + '-c', + command.replace('/workspace', str(workspace)), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + assert process.returncode == 0, stderr.decode() + return stdout.decode().replace(str(workspace), '/workspace') + 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): + async def test_materialize_inbound_writes_and_describes(self, tmp_path): import base64 import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -1989,14 +2021,9 @@ class TestInboundOutboundRoundTrip: ] ) - # 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/query-42/image_1.png"]', - 'stderr': '', - } + assert q is query + return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''} service.execute_tool = AsyncMock(side_effect=fake_execute_tool) @@ -2006,6 +2033,7 @@ class TestInboundOutboundRoundTrip: assert d['type'] == 'Image' assert d['path'] == '/workspace/inbox/query-42/image_1.png' assert d['size'] == len(img_bytes) + assert (tmp_path / 'inbox/query-42/image_1.png').read_bytes() == img_bytes @pytest.mark.asyncio async def test_materialize_inbound_noop_without_attachments(self): @@ -2019,44 +2047,40 @@ class TestInboundOutboundRoundTrip: service.execute_tool.assert_not_called() @pytest.mark.asyncio - async def test_collect_outbound_reads_and_clears(self): + async def test_collect_outbound_reads_and_clears(self, tmp_path): service = self._service() query = make_query() + outbox = tmp_path / 'outbox/query-42' + outbox.mkdir(parents=True) + (outbox / 'out.png').write_bytes(b'ABC') + guard = tmp_path / 'outbox/query-other/protected.txt' + guard.parent.mkdir() + guard.write_bytes(b'other query') - calls = [] - - async def fake_client_execute(spec): - cmd = spec.cmd - calls.append(cmd) - if 'os.scandir' in cmd: - return BoxExecutionResult( - session_id='s', - backend_name='test', - status=BoxExecutionStatus.COMPLETED, - exit_code=0, - stdout='[{"name": "out.png", "b64": "QUJD"}]', - duration_ms=10, - ) - # the rm -rf cleanup call + async def fake_client_execute(spec, *, action_context=None): + assert action_context == _ACTION_CONTEXT return BoxExecutionResult( session_id='s', backend_name='test', status=BoxExecutionStatus.COMPLETED, exit_code=0, - stdout='', + stdout=await self._execute_command(spec.cmd, tmp_path), duration_ms=10, ) + async def fake_execute_tool(parameters, q): + assert q is query + return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''} + service.client.execute = AsyncMock(side_effect=fake_client_execute) - service.execute_tool = AsyncMock(return_value={'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 - service.execute_tool.assert_awaited_once() - assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] + + assert attachments == [{'type': 'Image', 'name': 'out.png', 'base64': 'data:image/png;base64,QUJD'}] + assert not (outbox / 'out.png').exists() + assert guard.read_bytes() == b'other query' + assert await service.collect_outbound_attachments(query) == [] @pytest.mark.asyncio async def test_collect_outbound_empty_still_clears(self): @@ -2068,7 +2092,8 @@ class TestInboundOutboundRoundTrip: calls = [] - async def fake_client_execute(spec): + async def fake_client_execute(spec, *, action_context=None): + assert action_context.workspace_uuid == query.workspace_uuid cmd = spec.cmd calls.append(cmd) if 'os.scandir' in cmd: @@ -2092,9 +2117,9 @@ class TestInboundOutboundRoundTrip: service.client.execute = AsyncMock(side_effect=fake_client_execute) service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''}) assert await service.collect_outbound_attachments(query) == [] - # cleanup (rm -rf) is issued unconditionally now + # Cleanup is issued even when the outbox is empty. service.execute_tool.assert_awaited_once() - assert 'rm -rf' in service.execute_tool.await_args.args[0]['command'] + assert 'clear_directory' in service.execute_tool.await_args.args[0]['command'] @pytest.mark.asyncio async def test_passthrough_noop_when_unavailable(self): @@ -2162,6 +2187,7 @@ class TestAttachmentHostPath: stale_dir = os.path.join(ws, 'inbox', 'query-42') os.makedirs(stale_dir, exist_ok=True) pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE') + pathlib.Path(stale_dir, 'leftover-secret.txt').write_bytes(b'STALE-OTHER-FILE') new = b'\x89PNG\r\n\x1a\n NEW' b64 = 'data:image/png;base64,' + base64.b64encode(new).decode() @@ -2175,6 +2201,7 @@ class TestAttachmentHostPath: assert host_bytes == new # No leftover content from the stale image. assert b'STALE-OLD-IMAGE' not in host_bytes + assert sorted(os.listdir(stale_dir)) == ['image_1.png'] @pytest.mark.asyncio async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path): @@ -2386,3 +2413,31 @@ async def test_execution_cannot_override_runner_bound_session(): await service.execute_spec_payload({'cmd': 'true', 'session_id': 'other-box'}, make_query()) assert backend.start_calls == [] + + +@pytest.mark.asyncio +async def test_execute_tool_preserves_trusted_read_only_mounts(tmp_path): + logger = Mock() + backend = FakeBackend(logger) + runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) + service = BoxService( + make_app(logger, allowed_mount_roots=[str(tmp_path)]), + client=_InProcessBoxRuntimeClient(logger, runtime), + ) + package = tmp_path / 'published' + package.mkdir() + await service.initialize() + try: + result = await service.execute_tool( + {'command': 'cat /workspace/.skills/demo/SKILL.md'}, + make_query(), + read_only_mounts=[{'host_path': str(package), 'mount_path': '/workspace/.skills/demo', 'mode': 'ro'}], + ) + assert result['exit_code'] == 0 + assert len(backend.start_specs) == 1 + mount = backend.start_specs[0].extra_mounts[0] + assert mount.host_path == str(package) + assert mount.mount_path == '/workspace/.skills/demo' + assert mount.mode == BoxHostMountMode.READ_ONLY + finally: + await service.shutdown() diff --git a/tests/unit_tests/box/test_outbox_permissions.py b/tests/unit_tests/box/test_outbox_permissions.py new file mode 100644 index 000000000..0adf9f8e5 --- /dev/null +++ b/tests/unit_tests/box/test_outbox_permissions.py @@ -0,0 +1,319 @@ +"""Permission recovery must keep sandbox exports inside the bound run.""" + +import asyncio +import errno +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus +from langbot.pkg.box import secure_fs +from tests.unit_tests.box import test_box_service as fixtures + + +@pytest.mark.parametrize('denied_component', ['root', 'outbox', 'run', 'nested', 'answer.txt']) +def test_unreadable_host_file_is_not_silently_dropped(tmp_path, monkeypatch, denied_component): + outbox = tmp_path / 'outbox' / 'run' / 'nested' + outbox.mkdir(parents=True) + (outbox / 'answer.txt').write_bytes(b'private output') + original_open = os.open + + def denied_open(path, flags, *args, **kwargs): + target = str(tmp_path) if denied_component == 'root' else denied_component + if path == target: + raise PermissionError(errno.EACCES, 'container-owned output', path) + return original_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(secure_fs.os, 'open', denied_open) + with pytest.raises(PermissionError): + secure_fs.read_regular_files( + str(tmp_path), 'outbox', 'run', max_file_bytes=1024, max_files=20, max_total_bytes=4096 + ) + + +def test_host_outbox_skips_fifo_without_waiting_for_a_writer(tmp_path): + outbox = tmp_path / 'outbox' / 'run' + outbox.mkdir(parents=True) + os.mkfifo(outbox / 'pipe') + script = ( + 'from langbot.pkg.box import secure_fs; ' + f'assert secure_fs.read_regular_files({str(tmp_path)!r}, "outbox", "run", ' + 'max_file_bytes=1024, max_files=20, max_total_bytes=4096) == []' + ) + subprocess.run([sys.executable, '-c', script], check=True, timeout=3) + + +@pytest.mark.asyncio +async def test_unreadable_host_output_uses_the_bound_sandbox(tmp_path): + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + outbox = Path(workspace) / 'outbox' + current = outbox / 'query-42' + current.mkdir(parents=True) + (current / 'answer.txt').write_bytes(b'private output') + (current / ' answer.txt ').write_bytes(b'spaced output') + other = outbox / 'other-run' + other.mkdir() + (other / 'secret.txt').write_bytes(b'other run secret') + (current / 'leak.txt').symlink_to(other / 'secret.txt') + service.OUTBOX_MOUNT_DIR = str(outbox) + service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output')) + service._clear_outbox = AsyncMock() + observed = [] + + async def execute(spec, *, action_context): + assert action_context == fixtures._ACTION_CONTEXT + assert spec.session_id == 'person_test_user' + assert spec.host_path == workspace + process = await asyncio.create_subprocess_shell( + spec.cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + observed.append(spec) + return BoxExecutionResult( + session_id=spec.session_id, + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=process.returncode, + stdout=stdout.decode(), + stderr=stderr.decode(), + duration_ms=0, + ) + + service.client.execute = AsyncMock(side_effect=execute) + attachments = await service.collect_outbound_attachments(query) + assert {item['name']: item['base64'] for item in attachments} == { + 'answer.txt': 'cHJpdmF0ZSBvdXRwdXQ=', + ' answer.txt ': 'c3BhY2VkIG91dHB1dA==', + } + assert len(observed) == 1 + assert (other / 'secret.txt').read_bytes() == b'other run secret' + service._clear_outbox.assert_awaited_once_with(query, str(current)) + + +def _script(root, operation='read', **limits): + from langbot.pkg.box.sandbox_files import outbox_script + + return outbox_script( + str(root / 'workspace' / 'outbox'), + 'run', + operation=operation, + max_file_bytes=limits.get('max_file_bytes', 1024), + max_files=limits.get('max_files', 20), + max_total_bytes=limits.get('max_total_bytes', 4096), + ) + + +def _run_script(script): + return subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, timeout=3) + + +@pytest.mark.parametrize('operation', ['read', 'clear']) +@pytest.mark.parametrize('component', ['workspace', 'outbox', 'run']) +def test_sandbox_outbox_never_follows_ancestor_links(tmp_path, operation, component): + workspace = tmp_path / 'workspace' + current = workspace / 'outbox' / 'run' + current.mkdir(parents=True) + other = tmp_path / 'other' + other.mkdir() + target = { + 'workspace': other / 'outbox' / 'run', + 'outbox': other / 'run', + 'run': other, + }[component] + target.mkdir(parents=True, exist_ok=True) + protected = target / 'secret.txt' + protected.write_bytes(b'other run secret') + import shutil + + link = {'workspace': workspace, 'outbox': current.parent, 'run': current}[component] + shutil.rmtree(link) + link.symlink_to(other, target_is_directory=True) + result = _run_script(_script(tmp_path, operation)) + assert result.returncode != 0 + assert protected.read_bytes() == b'other run secret' + + +@pytest.mark.parametrize('replacement', ['symlink', 'fifo', 'directory']) +def test_sandbox_file_open_resists_type_changes(tmp_path, replacement): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + (current / 'answer.txt').write_bytes(b'expected') + secret = tmp_path / 'secret.txt' + secret.write_bytes(b'outside secret') + # Change the real filesystem after classification, immediately before open. + prelude = f""" +import os +original_open = os.open +replacement = {replacement!r} +def racing_open(path, flags, *args, **kwargs): + fd = kwargs.get('dir_fd') + if path == 'answer.txt' and fd is not None: + os.unlink(path, dir_fd=fd) + if replacement == 'symlink': + os.symlink({str(secret)!r}, path, dir_fd=fd) + elif replacement == 'fifo': + os.mkfifo(path, dir_fd=fd) + else: + os.mkdir(path, dir_fd=fd) + child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd) + try: + output = original_open('secret.txt', os.O_WRONLY | os.O_CREAT, 0o600, dir_fd=child) + os.write(output, b'unexpected directory') + os.close(output) + finally: + os.close(child) + return original_open(path, flags, *args, **kwargs) +os.open = racing_open +""" + result = _run_script(prelude + _script(tmp_path)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + assert secret.read_bytes() == b'outside secret' + + +@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'total_bytes_exact', 'entries', 'directories']) +def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit): + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + options = {} + if limit == 'file_bytes': + (current / 'answer.txt').write_bytes(b'1234') + options['max_file_bytes'] = 3 + elif limit in {'total_bytes', 'total_bytes_exact'}: + (current / 'a.txt').write_bytes(b'12') + (current / 'b.txt').write_bytes(b'34') + options['max_total_bytes'] = 3 + if limit == 'total_bytes_exact': + (current / 'c.txt').write_bytes(b'56') + options['max_total_bytes'] = 4 + elif limit == 'entries': + for index in range(513): + (current / str(index)).symlink_to(tmp_path / 'missing') + else: + for index in range(65): + (current / str(index)).mkdir() + before = set(current.iterdir()) + result = _run_script(_script(tmp_path, **options)) + assert result.returncode != 0 + assert 'limit' in result.stderr + assert set(current.iterdir()) == before + + +def test_sandbox_output_count_and_depth_are_bounded(tmp_path): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + (current / 'a.txt').write_bytes(b'a') + (current / 'b.txt').write_bytes(b'b') + result = _run_script(_script(tmp_path, max_files=1)) + assert result.returncode == 0, result.stderr + assert len(json.loads(result.stdout)) == 1 + (current / 'a.txt').unlink() + (current / 'b.txt').unlink() + deep = current.joinpath(*('nested' for _ in range(9))) + deep.mkdir(parents=True) + (deep / 'too-deep.txt').write_bytes(b'not exported') + result = _run_script(_script(tmp_path)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + + +@pytest.mark.parametrize('operation', ['read', 'clear']) +def test_sandbox_nested_links_do_not_touch_their_targets(tmp_path, operation): + import json + + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + other = tmp_path / 'other' + other.mkdir() + protected = other / 'secret.txt' + protected.write_bytes(b'protected') + (current / 'linked-dir').symlink_to(other, target_is_directory=True) + (current / 'linked-file').symlink_to(protected) + result = _run_script(_script(tmp_path, operation)) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [] + assert protected.read_bytes() == b'protected' + if operation == 'clear': + assert list(current.iterdir()) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['permission', 'truncated-json']) +async def test_permission_recovery_failure_does_not_clear_output(tmp_path, failure): + from langbot_plugin.box.errors import BoxValidationError + + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + current = Path(workspace) / 'outbox' / 'query-42' + current.mkdir(parents=True) + output = current / 'answer.txt' + output.write_bytes(b'keep on failure') + service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output')) + service._clear_outbox = AsyncMock() + service.client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=1 if failure == 'permission' else 0, + stdout='' if failure == 'permission' else '[{"name": "answer.txt", "b64": "truncated', + stderr='Permission denied' if failure == 'permission' else '', + duration_ms=0, + ) + ) + error = 'Failed to read sandbox outbox' if failure == 'permission' else 'invalid attachment data' + with pytest.raises(BoxValidationError, match=error): + await service.collect_outbound_attachments(query) + service._clear_outbox.assert_not_awaited() + assert output.read_bytes() == b'keep on failure' + + +@pytest.mark.asyncio +async def test_changed_outbox_ancestor_never_uses_cleanup_exec(tmp_path): + service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path) + query = fixtures.make_query() + outbox = Path(workspace) / 'outbox' + other = tmp_path / 'other' + protected = other / 'query-42' / 'secret.txt' + protected.parent.mkdir(parents=True) + protected.write_bytes(b'keep other run') + outbox.symlink_to(other, target_is_directory=True) + service.execute_tool = AsyncMock(side_effect=AssertionError('unsafe host ancestor must not use exec')) + await service._clear_outbox(query, str(outbox / 'query-42')) + service.execute_tool.assert_not_awaited() + assert protected.read_bytes() == b'keep other run' + assert any('unsafe sandbox outbox' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list) + + +def test_sandbox_cleanup_has_a_bounded_inode_budget(tmp_path): + current = tmp_path / 'workspace' / 'outbox' / 'run' + current.mkdir(parents=True) + for index in range(4097): + (current / str(index)).write_bytes(b'') + result = _run_script(_script(tmp_path, 'clear')) + assert result.returncode != 0 + assert 'inode limit' in result.stderr + assert list(current.iterdir()) + + +def test_host_reset_preserves_permission_error_for_private_run_directory(tmp_path, monkeypatch): + current = tmp_path / 'outbox' / 'run' + current.mkdir(parents=True) + original_open = os.open + + def denied_open(path, flags, *args, **kwargs): + if path == 'run' and kwargs.get('dir_fd') is not None: + raise PermissionError(errno.EACCES, 'private container directory', path) + return original_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(secure_fs.os, 'open', denied_open) + with pytest.raises(PermissionError): + secure_fs.reset_directory(str(tmp_path), 'outbox', 'run') diff --git a/tests/unit_tests/box/test_runner_box.py b/tests/unit_tests/box/test_runner_box.py index ad49482f5..74e4262e5 100644 --- a/tests/unit_tests/box/test_runner_box.py +++ b/tests/unit_tests/box/test_runner_box.py @@ -12,11 +12,12 @@ from langbot.pkg.box.runner import RunnerBoxService, RunBoxBinding, exported_mes def service(): sessions = {} - async def create(context, spec): + async def create(context, spec, **kwargs): sessions.setdefault(spec['session_id'], spec) return sessions[spec['session_id']] box = SimpleNamespace( + ap=SimpleNamespace(), enabled=True, managed_admission_required=False, _ATTACHMENT_MAX_TOTAL_BYTES=1000000, @@ -24,7 +25,6 @@ def service(): require_workspace_sandbox=AsyncMock(side_effect=lambda context: context), _action_context=lambda context: context, client=SimpleNamespace(get_sessions=AsyncMock(side_effect=lambda **kw: list(sessions.values()))), - build_skill_extra_mounts=lambda query: [], ) return RunnerBoxService(box) @@ -37,7 +37,7 @@ async def test_acquire_reuses_key_and_bind_is_explicit_and_run_scoped(): api.acquire('workspace', {'reuse_key': 'global'}), ) assert first == second - q1, q2 = SimpleNamespace(), SimpleNamespace() + q1, q2 = SimpleNamespace(variables={}), SimpleNamespace(variables={}) a = await api.bind('workspace', q1, 'run-a', first['id']) b = await api.bind('workspace', q2, 'run-b', first['id']) assert a['box_id'] == b['box_id'] @@ -54,7 +54,7 @@ async def test_concurrent_binding_cannot_switch_boxes(): api = service() a = await api.acquire('ws', {'reuse_key': 'a'}) b = await api.acquire('ws', {'reuse_key': 'b'}) - q = SimpleNamespace() + q = SimpleNamespace(variables={}) results = await asyncio.gather( api.bind('ws', q, 'run', a['id']), api.bind('ws', q, 'run', b['id']), return_exceptions=True ) diff --git a/tests/unit_tests/box/test_workspace.py b/tests/unit_tests/box/test_workspace.py index 48720a48a..f2a204413 100644 --- a/tests/unit_tests/box/test_workspace.py +++ b/tests/unit_tests/box/test_workspace.py @@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import ( classify_python_workspace, infer_workspace_host_path, rewrite_mounted_path, - wrap_python_command_with_env, ) +from langbot.pkg.utils.python_workspace import wrap_python_command_with_env _CONTEXT = ExecutionContext( diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 756baa7be..cc2a28e88 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import datetime import logging +import sqlite3 +from contextlib import closing from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -184,7 +186,16 @@ def _delta( async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context): application, _session_factory = projection_context - request_sync = Mock() + observed_revisions = [] + + def observe_committed_revision(): + with closing(sqlite3.connect(application.persistence_mgr.engine.url.database)) as connection: + row = connection.execute( + 'SELECT projection_revision FROM workspaces WHERE uuid = ?', (WORKSPACE_UUID,) + ).fetchone() + observed_revisions.append(row[0]) + + request_sync = Mock(side_effect=observe_committed_revision) application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync) event = DirectoryEvent( cursor=2, @@ -209,10 +220,12 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti ) await service.initialize() request_sync.reset_mock() + observed_revisions.clear() await service.sync_once() request_sync.assert_called_once_with() + assert observed_revisions == [2] async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context): @@ -247,11 +260,7 @@ async def test_targeted_reconciliation_projects_new_workspace_without_advancing_ async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context): application, session_factory = projection_context targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy( - update={ - 'members': [ - _member(revision=8).model_copy(update={'display_name': 'Changed Account Name'}) - ] - } + update={'members': [_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})]} ) provider = _Provider( [_snapshot(7)], @@ -572,7 +581,13 @@ async def test_account_projection_reads_large_directory_in_bounded_batches(proje assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(User)) == 501 -async def test_archived_and_absent_workspaces_are_execution_fenced(projection_context): +@pytest.mark.parametrize( + ('status', 'execution_state', 'write_fenced', 'membership_status'), + [('archived', 'inactive', True, 'removed'), ('active', 'active', False, 'active')], +) +async def test_archived_and_absent_workspaces_are_execution_fenced( + projection_context, status, execution_state, write_fenced, membership_status +): application, session_factory = projection_context service = DirectoryProjectionService( application, @@ -584,17 +599,17 @@ async def test_archived_and_absent_workspaces_are_execution_fenced(projection_co await service.apply_snapshot( _snapshot( 2, - workspaces=[_workspace(revision=2, status='archived')], + workspaces=[_workspace(revision=2, status=status)], ) ) async with session_factory() as session: workspace = await session.get(Workspace, WORKSPACE_UUID) execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID) membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) - assert workspace.status == 'archived' - assert execution.state == 'inactive' - assert execution.write_fenced is True - assert membership.status == 'removed' + assert workspace.status == status + assert execution.state == execution_state + assert execution.write_fenced is write_fenced + assert membership.status == membership_status await service.apply_snapshot(_snapshot(3, workspaces=[])) async with session_factory() as session: @@ -746,7 +761,15 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec high_water_cursor=2, events=[event], ) - provider = _Provider([_snapshot(1)], [batch], [_delta(workspaces=[])]) + second_workspace = _workspace().model_copy( + update={ + 'uuid': SECOND_WORKSPACE_UUID, + 'slug': 'second-workspace', + 'type': 'team', + 'members': [_member().model_copy(update={'membership_uuid': SECOND_MEMBERSHIP_UUID})], + } + ) + provider = _Provider([_snapshot(1, workspaces=[_workspace(), second_workspace])], [batch], [_delta(workspaces=[])]) service = DirectoryProjectionService(application, provider, INSTANCE_UUID) await service.initialize() @@ -755,7 +778,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec async with session_factory() as session: workspace = await session.get(Workspace, WORKSPACE_UUID) execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID) - membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + membership = await session.scalar( + sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == WORKSPACE_UUID) + ) + untouched_workspace = await session.get(Workspace, SECOND_WORKSPACE_UUID) + untouched_execution = await session.get(WorkspaceExecutionState, SECOND_WORKSPACE_UUID) + untouched_membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID) assert workspace.status == 'archived' assert workspace.projection_revision == 2 assert execution.state == 'inactive' @@ -763,6 +791,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec assert execution.desired_state_revision == 2 assert membership.status == 'removed' assert membership.projection_revision == 2 + assert untouched_workspace.status == 'active' + assert untouched_workspace.projection_revision == 1 + assert untouched_execution.state == 'active' + assert untouched_execution.write_fenced is False + assert untouched_membership.status == 'active' + assert untouched_membership.projection_revision == 1 stale_same_cursor_snapshot = _snapshot( 2, diff --git a/tests/unit_tests/cloud/test_plan_policy.py b/tests/unit_tests/cloud/test_plan_policy.py new file mode 100644 index 000000000..20b5b36e8 --- /dev/null +++ b/tests/unit_tests/cloud/test_plan_policy.py @@ -0,0 +1,44 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError +from langbot.pkg.cloud.plan_policy import require_workspace_platform, workspace_history_retention_days + + +def cloud_app(): + snapshots = { + 'restricted': SimpleNamespace(features={'platform.telegram': False}, limits={'chat_history.retention_days': 7}, entitlement_revision=2), + 'open': SimpleNamespace(features={}, limits={'chat_history.retention_days': 90}, entitlement_revision=3), + } + return SimpleNamespace(deployment=SimpleNamespace(mode='cloud'), entitlement_resolver=SimpleNamespace(resolve=AsyncMock(side_effect=lambda ws: snapshots[ws]))) + + +@pytest.mark.asyncio +async def test_platform_policy_is_scoped_and_covers_aliases(): + ap = cloud_app() + for adapter in ['telegram', 'telegram-omni', 'telegram-eba']: + with pytest.raises(EntitlementFeatureUnavailableError): + await require_workspace_platform(ap, 'restricted', adapter) + await require_workspace_platform(ap, 'open', adapter) + await require_workspace_platform(ap, 'restricted', 'discord') + + +@pytest.mark.asyncio +async def test_retention_uses_workspace_entitlement(): + ap = cloud_app() + assert await workspace_history_retention_days(ap, 'restricted', 100) == 7 + assert await workspace_history_retention_days(ap, 'open', 100) == 90 + + +@pytest.mark.asyncio +async def test_oss_and_unavailable_projection(): + ap = SimpleNamespace(deployment=SimpleNamespace(mode='local')) + await require_workspace_platform(ap, 'any', 'telegram') + assert await workspace_history_retention_days(ap, 'any', 60) == 60 + ap.deployment.mode = 'cloud' + with pytest.raises(EntitlementUnavailableError): + await require_workspace_platform(ap, 'any', 'telegram') + with pytest.raises(EntitlementUnavailableError): + await workspace_history_retention_days(ap, 'any') diff --git a/tests/unit_tests/command/test_cmdmgr.py b/tests/unit_tests/command/test_cmdmgr.py index afc251a13..9510a84b2 100644 --- a/tests/unit_tests/command/test_cmdmgr.py +++ b/tests/unit_tests/command/test_cmdmgr.py @@ -219,90 +219,44 @@ class TestCommandManagerExecute: assert hasattr(result, '__aiter__') @pytest.mark.asyncio - async def test_execute_sets_privilege_for_admin(self): - """execute() sets privilege=2 for admin users.""" - - fake_app = FakeApp(admins=['person_12345']) - mgr = CommandManager(fake_app) - mgr.cmd_list = [] - - # Mock plugin_connector - fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) - - query = command_query('status') - query.launcher_type = provider_session.LauncherTypes.PERSON - query.launcher_id = 12345 - - session = self._create_session() - - results = [] - async for ret in mgr.execute('status', '/status', query, session): - results.append(ret) - - # Verify admin config was checked - assert 'person_12345' in fake_app.instance_config.data['admins'] - - @pytest.mark.asyncio - async def test_execute_sets_privilege_for_non_admin(self): - """execute() sets privilege=1 for non-admin users.""" - - fake_app = FakeApp(admins=['person_12345']) - mgr = CommandManager(fake_app) - mgr.cmd_list = [] - - fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) - - query = command_query('status') - query.launcher_type = provider_session.LauncherTypes.PERSON - query.launcher_id = 67890 # Not in admins list - - session = self._create_session(launcher_id=67890) - - results = [] - async for ret in mgr.execute('status', '/status', query, session): - results.append(ret) - - @pytest.mark.asyncio - async def test_execute_parses_command_text(self): - """execute() splits command_text into params.""" + @pytest.mark.parametrize(('is_admin', 'privilege'), [(True, 2), (False, 1)]) + @pytest.mark.parametrize('bound_plugins', [None, ['plugin1', 'plugin2']]) + async def test_execute_delivers_scoped_command_context(self, is_admin, privilege, bound_plugins): + from langbot_plugin.api.entities.builtin.command.context import CommandReturn fake_app = FakeApp() + fake_app.persistence_mgr.execute_async = AsyncMock( + return_value=Mock(first=Mock(return_value=object() if is_admin else None)) + ) + command = Mock() + command.metadata.name = 'help' + fake_app.plugin_connector.list_commands = AsyncMock(return_value=[command]) + received = [] + + async def execute_command(context, plugins): + received.append((context, plugins)) + yield CommandReturn(text='plugin response') + + fake_app.plugin_connector.execute_command = execute_command mgr = CommandManager(fake_app) mgr.cmd_list = [] - - fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) - query = command_query('help arg1 arg2') + query.variables['_pipeline_bound_plugins'] = bound_plugins session = self._create_session() - results = [] - async for ret in mgr.execute('help arg1 arg2', '/help arg1 arg2', query, session): - results.append(ret) + results = [result async for result in mgr.execute('help arg1 arg2', '/help arg1 arg2', query, session)] - # Command text parsing happens inside execute() - # We verify it doesn't crash - - @pytest.mark.asyncio - async def test_execute_passes_bound_plugins(self): - """execute() passes bound_plugins from query variables.""" - - fake_app = FakeApp() - mgr = CommandManager(fake_app) - mgr.cmd_list = [] - - fake_app.plugin_connector.list_commands = AsyncMock(return_value=[]) - - query = command_query('help') - query.variables = {'_pipeline_bound_plugins': ['plugin1', 'plugin2']} - - session = self._create_session() - - results = [] - async for ret in mgr.execute('help', '/help', query, session): - results.append(ret) - - # Bound plugins are extracted from query.variables - assert query.variables.get('_pipeline_bound_plugins') == ['plugin1', 'plugin2'] + assert [result.text for result in results] == ['plugin response'] + fake_app.plugin_connector.list_commands.assert_awaited_once_with(bound_plugins) + assert len(received) == 1 + context, plugins = received[0] + assert plugins == bound_plugins + assert context.privilege == privilege + assert context.command == context.crt_command == 'help' + assert context.command_text == 'help arg1 arg2' + assert context.full_command_text == '/help arg1 arg2' + assert context.params == ['help', 'arg1', 'arg2'] + assert context.crt_params == ['arg1', 'arg2'] class TestCommandManagerInternalExecute: @@ -420,31 +374,6 @@ class TestCommandManagerInternalExecute: assert selected.placement_generation == 4 assert selected.query_uuid == 'query-a' - @pytest.mark.asyncio - async def test_execute_with_bound_plugins(self): - """_execute passes bound_plugins to plugin connector.""" - - fake_app = FakeApp() - mgr = CommandManager(fake_app) - mgr.cmd_list = [] - - # Mock plugin command - mock_command = Mock() - mock_command.metadata.name = 'test_cmd' - - fake_app.plugin_connector.list_commands = AsyncMock(return_value=[mock_command]) - - async def mock_execute_command(ctx, bound_plugins): - yield Mock(text='ok') - - fake_app.plugin_connector.execute_command = mock_execute_command - - ctx = self._create_context(command='test_cmd') - - # Execute with bound_plugins parameter - async for _ in mgr._execute(ctx, mgr.cmd_list, bound_plugins=['test_plugin']): - pass - class TestEmptyAndEdgeInputs: """Tests for empty and edge inputs.""" @@ -508,8 +437,10 @@ class TestEmptyAndEdgeInputs: async for ret in mgr.execute(' ', '/ ', query, session): results.append(ret) - # Should yield error - assert len(results) >= 1 + assert len(results) == 1 + assert results[0].error is not None + assert results[0].error.message == '未知命令: ' + assert results[0].text is None @pytest.mark.asyncio async def test_initialize_with_deep_nesting(self): diff --git a/tests/unit_tests/config/test_config_loader.py b/tests/unit_tests/config/test_config_loader.py index ec9942076..edc315bbf 100644 --- a/tests/unit_tests/config/test_config_loader.py +++ b/tests/unit_tests/config/test_config_loader.py @@ -13,6 +13,7 @@ from __future__ import annotations import pytest import json +import yaml from langbot.pkg.config.impls.yaml import YAMLConfigFile from langbot.pkg.config.impls.json import JSONConfigFile @@ -126,8 +127,7 @@ name: custom_name assert config_file.exists() content = config_file.read_text() - assert 'saved_app' in content - assert 'new_key' in content + assert yaml.safe_load(content) == {'name': 'saved_app', 'new_key': 'new_value'} def test_yaml_save_sync(self, tmp_path): """YAML config can be saved synchronously.""" @@ -142,7 +142,7 @@ name: custom_name assert config_file.exists() content = config_file.read_text() - assert 'sync_saved' in content + assert yaml.safe_load(content) == {'name': 'sync_saved'} class TestJSONConfigFile: @@ -260,7 +260,7 @@ class TestConfigManager: await manager.dump_config() content = config_file.read_text() - assert 'dumped' in content + assert yaml.safe_load(content) == {'name': 'dumped', 'new_field': 'value'} def test_config_manager_dump_sync(self, tmp_path): """ConfigManager can dump config synchronously.""" @@ -277,6 +277,7 @@ class TestConfigManager: manager.dump_config_sync() assert config_file.exists() + assert yaml.safe_load(config_file.read_text()) == {'name': 'sync_dumped'} class TestConfigExists: diff --git a/tests/unit_tests/core/test_app_resource_maintenance.py b/tests/unit_tests/core/test_app_resource_maintenance.py index 8ba094d1f..c29af0228 100644 --- a/tests/unit_tests/core/test_app_resource_maintenance.py +++ b/tests/unit_tests/core/test_app_resource_maintenance.py @@ -32,7 +32,11 @@ async def _wait_forever() -> None: await asyncio.Event().wait() -async def test_resource_maintenance_waits_and_shares_workspace_discovery() -> None: +async def test_resource_maintenance_waits_and_shares_workspace_discovery(monkeypatch) -> None: + async def idle_leases(_ap): + await _wait_forever() + + monkeypatch.setattr('langbot.pkg.utils.execution_lease.maintain_execution_leases', idle_leases) stop = asyncio.Event() completed = asyncio.Event() discovery_calls = 0 diff --git a/tests/unit_tests/core/test_boot.py b/tests/unit_tests/core/test_boot.py index 03c82a732..f6cf657d4 100644 --- a/tests/unit_tests/core/test_boot.py +++ b/tests/unit_tests/core/test_boot.py @@ -36,6 +36,10 @@ async def test_make_app_shuts_down_partially_built_application(monkeypatch): @pytest.mark.asyncio async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch): captured_handler = {} + print_exc = Mock() + monkeypatch.setattr(boot.traceback, 'print_exc', print_exc) + # Signal handling must not touch a potentially blocked stdout stream. + monkeypatch.setattr(boot, 'print', Mock(side_effect=AssertionError('stdout must not be used')), raising=False) def fake_signal(sig, handler): captured_handler[sig] = handler @@ -48,6 +52,8 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch await boot.main(SimpleNamespace()) + print_exc.assert_not_called() + @pytest.mark.asyncio async def test_main_signal_handler_disposes_created_app(monkeypatch): diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index 13fa156d7..c93604a07 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -50,8 +50,7 @@ class TestCheckDeps: result = asyncio.run(check_deps()) - assert 'requests' in result - assert 'openai' in result + assert result == ['requests', 'openai'] def test_check_deps_all_missing(self): """check_deps returns all deps when none present.""" @@ -66,8 +65,7 @@ class TestCheckDeps: result = asyncio.run(check_deps()) - # Should include all required_deps keys - assert len(result) == len(required_deps) + assert result == list(required_deps) def test_required_deps_dict_exists(self): """required_deps dictionary is defined.""" diff --git a/tests/unit_tests/core/test_bootutils_files.py b/tests/unit_tests/core/test_bootutils_files.py new file mode 100644 index 000000000..c2a59209f --- /dev/null +++ b/tests/unit_tests/core/test_bootutils_files.py @@ -0,0 +1,48 @@ +"""Regression tests for bootutils.files.generate_files. + +Historically the directory-creation loop iterated as +``for required_paths in required_paths:`` while the function declared +``global required_paths``, which rebound the module-level list to the last +iterated string (``'data/labels'``) after the first call. A second call then +iterated over that string character by character and attempted to create +single-character directories. These tests pin the module-level state and the +repeat-call behaviour. +""" + +from __future__ import annotations + +import asyncio +from importlib import import_module + + +def get_files_module(): + """Lazy import to avoid circular import issues.""" + return import_module('langbot.pkg.core.bootutils.files') + + +def test_required_paths_stays_a_list_after_generate_files(tmp_path, monkeypatch): + files = get_files_module() + monkeypatch.chdir(tmp_path) + original = list(files.required_paths) + + asyncio.run(files.generate_files()) + + assert isinstance(files.required_paths, list) + assert files.required_paths == original + + +def test_generate_files_is_idempotent(tmp_path, monkeypatch): + files = get_files_module() + monkeypatch.chdir(tmp_path) + + first = asyncio.run(files.generate_files()) + second = asyncio.run(files.generate_files()) + + for path in files.required_paths: + assert (tmp_path / path).is_dir() + # The config template is copied on the first run only. + assert first == ['data/config.yaml'] + assert second == [] + assert (tmp_path / 'data' / 'config.yaml').is_file() + # No junk single-character directories left behind by a second run. + assert sorted(p.name for p in tmp_path.iterdir()) == ['data', 'temp'] diff --git a/tests/unit_tests/core/test_bootutils_log.py b/tests/unit_tests/core/test_bootutils_log.py index fd01eb9c2..e785b7b78 100644 --- a/tests/unit_tests/core/test_bootutils_log.py +++ b/tests/unit_tests/core/test_bootutils_log.py @@ -10,14 +10,10 @@ 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)) @@ -105,6 +101,8 @@ class TestDailyGroupedRotatingFileHandler: assert f'langbot-{day}.log' in files def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch): + from langbot.pkg.api.http.service.maintenance import LOG_FILE_PATTERN + box = {'date': '2026-06-08'} self._patch_date(monkeypatch, box) @@ -117,4 +115,4 @@ class TestDailyGroupedRotatingFileHandler: handler.close() for name in _listing(tmp_path): - assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name + assert LOG_FILE_PATTERN.match(name), name diff --git a/tests/unit_tests/core/test_console_logging.py b/tests/unit_tests/core/test_console_logging.py new file mode 100644 index 000000000..40a62b165 --- /dev/null +++ b/tests/unit_tests/core/test_console_logging.py @@ -0,0 +1,259 @@ +"""Console backpressure must not stop request handling or process shutdown.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import subprocess +import sys +import textwrap +import threading +import time +import weakref + +import pytest + +from langbot.pkg.core.bootutils import log as logmod +from langbot.pkg.core.bootutils.log import NonBlockingConsoleHandler + + +def _logger(handler): + logger = logging.Logger('console-test', logging.INFO) + logger.addHandler(handler) + return logger + + +def test_console_truncation_preserves_utf8_and_other_handlers(tmp_path): + message = '界🙂' * logmod.CONSOLE_RECORD_MAX_BYTES + with (tmp_path / 'console').open('w+b') as stream: + handler = NonBlockingConsoleHandler(stream) + logger = _logger(handler) + other = logging.FileHandler(tmp_path / 'full.log', encoding='utf-8') + logger.addHandler(other) + try: + logger.info('%s', message) + handler.close() + assert not handler._worker.is_alive() + stream.seek(0) + payload = stream.read() + assert len(payload) <= logmod.CONSOLE_RECORD_MAX_BYTES + assert payload.decode('utf-8').endswith('[console log truncated]\n') + assert (tmp_path / 'full.log').read_text(encoding='utf-8') == message + '\n' + finally: + handler.close() + other.close() + + +def test_close_with_full_queue_has_bounded_wait_and_reports_drops(tmp_path): + entered = threading.Event() + release = threading.Event() + + class StalledConsole(NonBlockingConsoleHandler): + def _write(self, payload): + entered.set() + assert release.wait(5), 'test writer was not released' + super()._write(payload) + + with (tmp_path / 'console').open('w+b') as stream: + handler = StalledConsole(stream, capacity=2) + logger = _logger(handler) + try: + logger.info('first') + assert entered.wait(2) + logger.info('second') + logger.info('third') + logger.info('overflow') + assert handler.dropped_records == 1 + started = time.monotonic() + handler.close() + assert time.monotonic() - started < 1 + logger.info('after close') + assert handler.dropped_records == 3 + finally: + release.set() + handler.close() + handler._worker.join(timeout=2) + assert not handler._worker.is_alive() + stream.seek(0) + output = stream.read().decode('utf-8') + assert 'first\n' in output + assert 'third\n' in output + assert 'Console logging dropped 3 records' in output + + +def test_broken_console_does_not_write_errors_to_stderr(tmp_path, capsys): + class BrokenConsole(NonBlockingConsoleHandler): + def _write(self, payload): + raise BrokenPipeError('reader exited') + + with (tmp_path / 'console').open('w+b') as stream: + handler = BrokenConsole(stream) + logger = _logger(handler) + try: + logger.info('first') + assert handler._failed.wait(2) + logger.info('second') + handler._worker.join(timeout=2) + assert handler.dropped_records == 2 + assert 'reader exited' in handler.write_error + assert handler._queue.empty() + finally: + handler.close() + assert capsys.readouterr().err == '' + + +def test_logging_shutdown_drains_healthy_console_without_handler_lock_deadlock(tmp_path): + entered = threading.Event() + release = threading.Event() + + class Console(NonBlockingConsoleHandler): + def _write(self, payload): + entered.set() + assert release.wait(5), 'test writer was not released' + super()._write(payload) + + def close(self): + release.set() + super().close() + + with (tmp_path / 'console').open('w+b') as stream: + handler = Console(stream) + logger = _logger(handler) + try: + logger.info('first') + assert entered.wait(2) + for index in range(20): + logger.info('queued %d', index) + # The standard shutdown holds handler.lock across flush and close. + logging.shutdown([weakref.ref(handler)]) + assert not handler._worker.is_alive() + assert handler.dropped_records == 0 + stream.seek(0) + assert stream.read().decode('utf-8').splitlines() == ['first', *(f'queued {i}' for i in range(20))] + finally: + release.set() + handler.close() + handler._worker.join(timeout=2) + + +@pytest.mark.asyncio +async def test_reinitialization_closes_previous_handlers(tmp_path, monkeypatch): + monkeypatch.setattr(logmod, 'LOG_DIR', str(tmp_path)) + with (tmp_path / 'console').open('w+b') as stream: + monkeypatch.setattr(logmod.sys, 'stdout', stream) + logger = await logmod.init_logging() + previous = logger.handlers[:] + try: + logger.info('before reinitialization') + await logmod.init_logging() + logger.info('after reinitialization') + assert len(logger.handlers) == 2 + assert all(handler._closed for handler in previous) + assert not previous[0]._worker.is_alive() + assert previous[1].stream is None + finally: + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() + + +@pytest.mark.parametrize('buffered_startup', [False, True], ids=['direct-init', 'buffered-entrypoint']) +def test_unread_stdout_keeps_webui_file_cache_and_process_exit_working(tmp_path, buffered_startup): + # The parent deliberately never reads stdout until the child exits. This + # exercises an actual full OS pipe, including interpreter finalization. + script = textwrap.dedent("""\ + import asyncio + import json + import logging + import os + from pathlib import Path + import sys + import time + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from quart import Quart + from langbot.pkg.core.bootutils import log + from langbot.pkg.core.stages.setup_logger import PersistenceHandler + from langbot.pkg.utils.logcache import LogCache + + async def main(): + Path('data/logs').mkdir(parents=True) + cache = LogCache() + logger = await log.init_logging([PersistenceHandler('cache', SimpleNamespace(log_cache=cache))]) + console = logger.handlers[0] + app = Quart(__name__) + + @app.get('/health') + async def health(): + logger.info('health request completed') + return {'ok': True} + + async def produce(): + for index in range(800): + logger.info('record %d %s', index, 'x' * 2048) + if index % 16 == 0: + await asyncio.sleep(0) + + producer = asyncio.create_task(produce()) + await producer + response = await app.test_client().get('/health') + assert response.status_code == 200 + assert await response.get_json() == {'ok': True} + assert console.dropped_records > 0 + with console._queue.mutex: + queued = list(console._queue.queue) + assert len(queued) <= log.CONSOLE_QUEUE_CAPACITY + assert all(isinstance(item, bytes) and len(item) <= log.CONSOLE_RECORD_MAX_BYTES for item in queued) + assert 'health request completed' in cache.get_log_by_pointer(0, 0)[0] + assert any('health request completed' in path.read_text(encoding='utf-8') + for path in Path('data/logs').glob('*.log')) + started = time.monotonic() + logging.shutdown() + assert time.monotonic() - started < 1 + Path('result.json').write_text(json.dumps({'dropped': console.dropped_records}), encoding='utf-8') + + if os.environ['LANGBOT_TEST_BUFFERED_STARTUP'] == '1': + from langbot import __main__ as entrypoint + from langbot.pkg.core import boot + from langbot.pkg.core.bootutils import deps, files + + # Run the actual entrypoint's banner print without starting bots, + # installing dependencies, or loading a real configuration. + assert not sys.stdout.line_buffering + assert not sys.stdout.write_through + deps.check_deps = AsyncMock(return_value=[]) + files.generate_files = AsyncMock(return_value=[]) + + async def run_after_startup(loop): + await main() + + boot.main = run_after_startup + entrypoint.main() + else: + asyncio.run(main()) + """) + env = dict( + os.environ, + PYTHONPATH=str(Path(logmod.__file__).resolve().parents[4]), + PYTHONIOENCODING='utf-8', + LANGBOT_TEST_BUFFERED_STARTUP=str(int(buffered_startup)), + ) + env.pop('PYTHONUNBUFFERED', None) + with subprocess.Popen( + [sys.executable, '-c', script], + cwd=tmp_path, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as process: + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + _, stderr = process.communicate() + pytest.fail(f'Logging or process exit blocked on stdout: {stderr.decode(errors="replace")}') + _, stderr = process.communicate() + assert process.returncode == 0, stderr.decode(errors='replace') + assert (tmp_path / 'result.json').exists() diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index 051597875..b24a36b8f 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -119,12 +119,11 @@ class TestApplyEnvOverridesToConfig: """Test that '1', 'yes', 'on' are treated as true.""" load_config = get_load_config_module() - cfg = {'system': {'flag': False}} - for true_val in ['1', 'yes', 'on', 'TRUE']: + cfg = {'system': {'flag': False}} env = {'SYSTEM__FLAG': true_val} with patch.dict(os.environ, env, clear=True): - result = load_config._apply_env_overrides_to_config(cfg.copy()) + result = load_config._apply_env_overrides_to_config(cfg) assert result['system']['flag'] is True def test_override_float_value(self): @@ -237,7 +236,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result['system']['name'] == 'default' + assert result == {'system': {'name': 'default'}} def test_skip_env_vars_with_empty_path_segments(self, capsys): """Platform variables such as __CF_USER_TEXT_ENCODING are not config.""" @@ -249,7 +248,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result == cfg + assert result == {'system': {'name': 'default'}} assert capsys.readouterr().out == '' def test_nested_config_path(self): @@ -299,7 +298,7 @@ class TestApplyEnvOverridesToConfig: with patch.dict(os.environ, env, clear=True): result = load_config._apply_env_overrides_to_config(cfg) - assert result == cfg + assert result == {'system': {'name': 'default'}} def test_multiple_env_vars_override(self): """Test multiple env vars applied in order.""" diff --git a/tests/unit_tests/core/test_stage.py b/tests/unit_tests/core/test_stage.py index e9b29e76b..795f52ef6 100644 --- a/tests/unit_tests/core/test_stage.py +++ b/tests/unit_tests/core/test_stage.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from unittest.mock import MagicMock import pytest @@ -41,11 +42,11 @@ class TestStageClassDecorator: with isolated_sys_modules(mocks): from langbot.pkg.core.stage import stage_class - @stage_class('TestStage') class TestStage: value = 42 - # Class attributes should be preserved + decorated = stage_class('TestStage')(TestStage) + assert decorated is TestStage assert TestStage.value == 42 def test_stage_class_multiple_stages(self): @@ -125,25 +126,15 @@ class TestBootingStageAbstract: # Abstract class has name attribute defined as None assert hasattr(BootingStage, 'name') - @pytest.mark.asyncio - async def test_booting_stage_run_signature(self): - """run method receives Application parameter.""" + def test_booting_stage_run_signature(self): + """The production abstract stage accepts an Application asynchronously.""" mocks = self._make_stage_import_mocks() with isolated_sys_modules(mocks): from langbot.pkg.core.stage import BootingStage - class TestStage(BootingStage): - name = 'TestStage' - - async def run(self, ap): - self.ap_received = ap - - stage = TestStage() - mock_ap = MagicMock() - - await stage.run(mock_ap) - assert stage.ap_received == mock_ap + assert list(inspect.signature(BootingStage.run).parameters) == ['self', 'ap'] + assert inspect.iscoroutinefunction(BootingStage.run) class TestPreregisteredStages: @@ -160,19 +151,3 @@ class TestPreregisteredStages: from langbot.pkg.core.stage import preregistered_stages assert isinstance(preregistered_stages, dict) - - def test_preregistered_stages_key_is_string(self): - """Registry keys are stage names (strings).""" - mocks = self._make_stage_import_mocks() - - with isolated_sys_modules(mocks): - from langbot.pkg.core.stage import stage_class, preregistered_stages - - preregistered_stages.clear() - - @stage_class('MyStage') - class MyStage: - pass - - for key in preregistered_stages: - assert isinstance(key, str) diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py index 3477478bb..85d1657bb 100644 --- a/tests/unit_tests/core/test_taskmgr.py +++ b/tests/unit_tests/core/test_taskmgr.py @@ -15,7 +15,7 @@ import asyncio import contextvars import inspect import sys -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock, MagicMock, patch from contextlib import contextmanager from typing import Generator @@ -181,12 +181,14 @@ class TestTaskContext: ctx.set_current_action('test_action') ctx.trace('Test message') ctx.metadata['key'] = 'value' + ctx.metadata['count'] = 5 + ctx.metadata['items'] = ['a', 'b', 'c'] result = ctx.to_dict() assert result['current_action'] == 'test_action' assert 'Test message' in result['log'] - assert result['metadata'] == {'key': 'value'} + assert result['metadata'] == {'key': 'value', 'count': 5, 'items': ['a', 'b', 'c']} def test_static_new_factory(self): """Test TaskContext.new() factory method.""" @@ -209,19 +211,9 @@ class TestTaskContext: ctx1 = TaskContext.placeholder() ctx2 = TaskContext.placeholder() + assert isinstance(ctx1, TaskContext) assert ctx1 is ctx2 - def test_metadata_is_mutable_dict(self): - """Test that metadata is a mutable dict.""" - TaskContext, _, _ = get_taskmgr_classes() - ctx = TaskContext() - - ctx.metadata['count'] = 5 - ctx.metadata['items'] = ['a', 'b', 'c'] - - assert ctx.metadata['count'] == 5 - assert len(ctx.metadata['items']) == 3 - class TestTaskWrapper: """Tests for TaskWrapper class.""" @@ -346,7 +338,8 @@ class TestTaskWrapper: async def successful_coro(): return {'file_id': 'file-a'} - wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store') + with patch('time.time', return_value=1720000000.25): + wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store') await wrapper.task result = wrapper.to_public_dict() @@ -358,7 +351,7 @@ class TestTaskWrapper: 'status': 'succeeded', 'error': None, 'result': {'file_id': 'file-a'}, - 'created_at': result['created_at'], + 'created_at': 1720000000.25, } assert 'runtime' not in result assert 'traceback' not in str(result).lower() @@ -429,6 +422,29 @@ class TestTaskWrapper: class TestAsyncTaskManager: """Tests for AsyncTaskManager class.""" + @pytest.mark.asyncio + async def test_completed_retention_keeps_newest_tasks_and_running_task(self): + _, _, AsyncTaskManager = get_taskmgr_classes() + mock_app = create_mock_app() + mock_app.instance_config.data['system']['task_retention']['completed_limit'] = 2 + manager = AsyncTaskManager(mock_app) + running = manager.create_task(asyncio.Event().wait()) + + async def immediate_coro(): + return 'done' + + try: + completed = [manager.create_task(immediate_coro()) for _ in range(2)] + await asyncio.gather(*(wrapper.task for wrapper in completed)) + assert manager.tasks == [running, *completed] + + newest = manager.create_task(immediate_coro()) + await newest.task + assert manager.tasks == [running, completed[1], newest] + finally: + running.cancel() + await asyncio.gather(running.task, return_exceptions=True) + @pytest.mark.asyncio async def test_create_task_adds_to_list(self): """Test that create_task adds task to tasks list.""" diff --git a/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py index 4e3e4f0ad..b29f2152a 100644 --- a/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py +++ b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py @@ -46,4 +46,29 @@ def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exist assert conn.execute(sa.select(migrated)).mappings().one() == row migration.downgrade() assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')} + restored = sa.Table('bots', sa.MetaData(), autoload_with=conn) + bindings = conn.execute(sa.select(restored.c.event_bindings)).scalar_one() + assert bindings == [ + primary, + { + 'id': 'plugin_processor:one', + 'event_pattern': '*', + 'target_type': 'event_processor', + 'target_uuid': 'one', + 'enabled': True, + 'filters': [], + 'priority': 0, + 'order': 1, + }, + { + 'id': 'plugin_processor:two', + 'event_pattern': '*', + 'target_type': 'event_processor', + 'target_uuid': 'two', + 'enabled': False, + 'filters': [], + 'priority': 0, + 'order': 2, + }, + ] engine.dispose() diff --git a/tests/unit_tests/persistence/test_conversation_page_index.py b/tests/unit_tests/persistence/test_conversation_page_index.py new file mode 100644 index 000000000..5a2b46dc8 --- /dev/null +++ b/tests/unit_tests/persistence/test_conversation_page_index.py @@ -0,0 +1,52 @@ +"""Conversation paging index is safe for existing and fresh databases.""" + +import importlib + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage + + +def test_conversation_index_migration_preserves_history_and_matches_metadata(): + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes') + index_name = 'ix_monitoring_messages_conversation_page' + engine = sa.create_engine('sqlite:///:memory:') + metadata = sa.MetaData() + table = sa.Table( + 'monitoring_messages', + metadata, + *[ + sa.Column(name, sa.String, primary_key=name == 'id') + for name in ( + 'id', + 'workspace_uuid', + 'bot_id', + 'session_id', + 'timestamp', + 'message_content', + 'status', + 'execution_lease_expires_at', + ) + ], + ) + sa.Index('existing_index', table.c.workspace_uuid) + try: + with engine.begin() as connection: + metadata.create_all(connection) + connection.execute(table.insert().values(id='history', message_content='inline media')) + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + migration.upgrade() + indexes = { + index['name']: index['column_names'] for index in sa.inspect(connection).get_indexes(table.name) + } + expected = next(index for index in MonitoringMessage.__table__.indexes if index.name == index_name) + assert indexes[index_name] == [column.name for column in expected.columns] + migration.downgrade() + assert {index['name'] for index in sa.inspect(connection).get_indexes(table.name)} == {'existing_index'} + assert connection.scalar(sa.select(table.c.message_content)) == 'inline media' + migration.upgrade() + finally: + engine.dispose() diff --git a/tests/unit_tests/persistence/test_database_decorator.py b/tests/unit_tests/persistence/test_database_decorator.py index 8d66dbdbc..520ee47da 100644 --- a/tests/unit_tests/persistence/test_database_decorator.py +++ b/tests/unit_tests/persistence/test_database_decorator.py @@ -13,7 +13,7 @@ from __future__ import annotations import sys from contextlib import contextmanager from typing import Generator -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock import pytest @@ -174,40 +174,3 @@ class TestManagerClassDecorator: # BaseDatabaseManager has name annotation (type hint) # Check __annotations__ for the type hint assert 'name' in database.BaseDatabaseManager.__annotations__ - - def test_decorated_class_inherits_from_base(self): - """Test that decorated class properly inherits BaseDatabaseManager.""" - database = get_database_module() - - database.preregistered_managers.clear() - - @database.manager_class('test_inherit') - class TestChild(database.BaseDatabaseManager): - async def initialize(self): - pass - - assert issubclass(TestChild, database.BaseDatabaseManager) - # Has abstract method requirement satisfied - assert hasattr(TestChild, 'initialize') - - def test_decorator_preserves_class_methods(self): - """Test that decorator preserves existing class methods.""" - database = get_database_module() - - database.preregistered_managers.clear() - - @database.manager_class('preserve_test') - class ManagerWithMethods(database.BaseDatabaseManager): - custom_attr = 'test_value' - - async def initialize(self): - pass - - def custom_method(self): - return self.custom_attr - - assert ManagerWithMethods.custom_attr == 'test_value' - # Create instance to test method (with mock app) - mock_app = Mock() - instance = ManagerWithMethods(mock_app) - assert instance.custom_method() == 'test_value' diff --git a/tests/unit_tests/persistence/test_execution_lease_indexes.py b/tests/unit_tests/persistence/test_execution_lease_indexes.py new file mode 100644 index 000000000..d09e1dbfd --- /dev/null +++ b/tests/unit_tests/persistence/test_execution_lease_indexes.py @@ -0,0 +1,136 @@ +"""Lease maintenance must not scan a Workspace's completed execution history.""" + +import datetime +import importlib + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.ext.asyncio import create_async_engine + +# Register users and workspaces so create_all can resolve the foreign keys. +from langbot.pkg.entity.persistence import user, workspace # noqa: F401 +from langbot.pkg.entity.persistence.agent_run import AgentRun +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage +from langbot.pkg.utils.execution_lease import reconcile_execution_leases + + +MIGRATION = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes') +LEASE_INDEXES = { + 'agent_run': ('ix_agent_run_workspace_status_lease', ['workspace_id', 'status', 'execution_lease_expires_at']), + 'monitoring_messages': ( + 'ix_monitoring_messages_workspace_status_lease', + ['workspace_uuid', 'status', 'execution_lease_expires_at'], + ), +} + + +def _create_schema(connection, source): + Base.metadata.create_all(connection) + if source == 'migration': + for table, (index, _) in LEASE_INDEXES.items(): + next(item for item in Base.metadata.tables[table].indexes if item.name == index).drop(connection) + with Operations.context(MigrationContext.configure(connection)): + MIGRATION.upgrade() + + +@pytest.mark.parametrize('source', ['metadata', 'migration']) +def test_lease_index_migration_preserves_data_and_existing_indexes(source): + engine = sa.create_engine('sqlite:///:memory:') + try: + with engine.begin() as connection: + _create_schema(connection, source) + connection.execute(sa.insert(AgentRun).values(run_id='history', runner_id='runner', status='completed')) + original_indexes = { + table: {index['name'] for index in sa.inspect(connection).get_indexes(table)} for table in LEASE_INDEXES + } + with Operations.context(MigrationContext.configure(connection)): + MIGRATION.upgrade() + for table, (index, columns) in LEASE_INDEXES.items(): + indexes = {item['name']: item['column_names'] for item in sa.inspect(connection).get_indexes(table)} + assert indexes[index] == columns + assert original_indexes[table] <= indexes.keys() + + MIGRATION.downgrade() + for table in LEASE_INDEXES: + remaining = {item['name'] for item in sa.inspect(connection).get_indexes(table)} + removed = {index for index_table, index, _ in MIGRATION.INDEXES if index_table == table} + assert remaining == original_indexes[table] - removed + assert connection.scalar(sa.select(AgentRun.status)) == 'completed' + + MIGRATION.upgrade() + assert connection.scalar(sa.select(AgentRun.status)) == 'completed' + finally: + engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('source', ['metadata', 'migration']) +@pytest.mark.parametrize('model', [AgentRun, MonitoringMessage]) +async def test_lease_queries_use_workspace_status_expiry_index(model, source): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + now = datetime.datetime(2026, 10, 17) + statements = [] + + def capture_statement(connection, cursor, statement, parameters, context, executemany): + if statement.startswith(('SELECT ', 'UPDATE ')) and model.__tablename__ in statement: + statements.append((statement, parameters)) + + try: + async with engine.begin() as connection: + await connection.run_sync(lambda sync: _create_schema(sync, source)) + common = { + 'execution_owner_id': 'live-host', + 'execution_lease_expires_at': now - datetime.timedelta(seconds=1), + } + if model is AgentRun: + rows = [ + dict( + common, + run_id=str(i), + runner_id='runner', + workspace_id='workspace', + status='completed', + created_at=now, + updated_at=now, + ) + for i in range(2000) + ] + else: + rows = [ + dict( + common, + id=str(i), + workspace_uuid='workspace', + timestamp=now, + bot_id='bot', + bot_name='bot', + pipeline_id='pipeline', + pipeline_name='pipeline', + message_content='Historical message ' * 64, + session_id='session', + status='success', + level='info', + ) + for i in range(2000) + ] + await connection.execute(sa.insert(model), rows) + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture_statement) + try: + assert await reconcile_execution_leases(engine, 'workspace', now=now, owner_id='live-host') == 0 + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture_statement) + + assert len(statements) == 2 + async with engine.connect() as connection: + for statement, parameters in statements: + plan = (await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters)).all() + details = ' '.join(row[3] for row in plan) + assert LEASE_INDEXES[model.__tablename__][0] in details, details + assert 'execution_lease_expires_at' in details, details + assert 'USE TEMP B-TREE' not in details, details + finally: + await engine.dispose() diff --git a/tests/unit_tests/persistence/test_media_reference_text.py b/tests/unit_tests/persistence/test_media_reference_text.py new file mode 100644 index 000000000..9d2d7c899 --- /dev/null +++ b/tests/unit_tests/persistence/test_media_reference_text.py @@ -0,0 +1,139 @@ +"""Image reference contracts apply to ORM and Core writes, including new callers.""" + +import json + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent +from langbot.pkg.entity.persistence.event_log import EventLog +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage +from langbot.pkg.entity.persistence.transcript import Transcript +from langbot.pkg.persistence.media import MediaReferenceText +from langbot.pkg.persistence.tenant_uow import ScopedSessionTransactionError, _validate_scoped_statement_call + + +class PayloadBase(DeclarativeBase): + pass + + +class Payload(PayloadBase): + __tablename__ = 'payload' + id = sa.Column(sa.Integer, primary_key=True) + content = sa.Column(MediaReferenceText) + + +@pytest.fixture +async def engine(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + async with engine.begin() as conn: + await conn.run_sync(PayloadBase.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('writer', ['orm', 'insert', 'update']) +@pytest.mark.parametrize( + 'image', + [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'Image', 'url': 'data:image/png;base64,eA=='}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}}, + {'type': 'image', 'image_url': 'data:image/png;base64,eA=='}, + ], +) +async def test_unprocessed_images_are_rejected_at_the_bind_boundary(engine, writer, image): + content = json.dumps({'nested': [image]}) + async with engine.begin() as conn: + await conn.execute(sa.insert(Payload).values(id=1, content='original')) + with pytest.raises(sa.exc.StatementError, match='use MediaCache.externalize_chain_dump'): + if writer == 'orm': + async with AsyncSession(engine) as session: + session.add(Payload(id=2, content=content)) + await session.commit() + else: + statement = ( + sa.insert(Payload).values(id=2, content=content) + if writer == 'insert' + else sa.update(Payload).where(Payload.id == 1).values(content=content) + ) + async with engine.begin() as conn: + await conn.execute(statement) + async with engine.connect() as conn: + assert (await conn.execute(sa.select(Payload.content))).scalars().all() == ['original'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'content', + [ + None, + 'plain text', + '{historical invalid JSON', + json.dumps({'type': 'Plain', 'text': 'base64 is a transport encoding'}), + json.dumps({'type': 'Image', 'url': 'https://example.com/image.png'}), + json.dumps({'type': 'Image', 'url': '/api/v1/files/media/hash.png', 'base64': None}), + ], +) +async def test_plain_text_and_references_round_trip_unchanged(engine, content): + async with AsyncSession(engine) as session: + session.add(Payload(id=1, content=content)) + await session.commit() + async with engine.connect() as conn: + assert (await conn.execute(sa.select(Payload.content))).scalar_one() == content + + +@pytest.mark.asyncio +async def test_legacy_images_can_be_read_compared_and_redacted(engine): + content = json.dumps({'type': 'Image', 'base64': 'eA=='}) + async with engine.begin() as conn: + # Simulate a row created before the contract was introduced. + await conn.execute(sa.text('INSERT INTO payload (id, content) VALUES (1, :content)'), {'content': content}) + assert ( + await conn.execute(sa.select(Payload.content).where(Payload.content == content)) + ).scalar_one() == content + assert (await conn.execute(sa.select(Payload.id).where(Payload.content.like('%base64%')))).scalar_one() == 1 + updated = await conn.execute( + sa.update(Payload).where(Payload.content == content).values(content='{"type": "Image", "base64": null}') + ) + assert updated.rowcount == 1 + + +@pytest.mark.parametrize('dialect', [sqlite.dialect(), postgresql.dialect()], ids=['sqlite', 'postgresql']) +def test_contract_keeps_text_schema_and_handles_escaped_json_keys(dialect): + column_type = MediaReferenceText() + assert column_type.compile(dialect=dialect) == 'TEXT' + process = column_type.dialect_impl(dialect).bind_processor(dialect) + with pytest.raises(ValueError, match='Inline images cannot be persisted'): + process(r'{"type": "Image", "base\u00364": "eA=="}') + + +@pytest.mark.parametrize( + 'model, fields', + [ + (MonitoringMessage, ['message_content', 'variables']), + (EventLog, ['input_json', 'metadata_json']), + (AgentRun, ['metadata_json']), + (AgentRunEvent, ['data_json', 'metadata_json']), + (Transcript, ['content_json', 'attachment_refs_json', 'metadata_json']), + ], +) +def test_conversation_payload_columns_declare_the_contract(model, fields): + for field in fields: + assert isinstance(model.__table__.c[field].type, MediaReferenceText), f'{model.__tablename__}.{field}' + + +def test_tenant_queries_admit_only_the_exact_trusted_media_type(): + _validate_scoped_statement_call((sa.select(Payload.content),), {}) + + class UntrustedMediaReferenceText(MediaReferenceText): + pass + + for column_type in (UntrustedMediaReferenceText(), MediaReferenceText(collation=sa.sql.quoted_name('x', False))): + with pytest.raises(ScopedSessionTransactionError): + _validate_scoped_statement_call((sa.select(sa.column('content', column_type)),), {}) diff --git a/tests/unit_tests/persistence/test_mgr_methods.py b/tests/unit_tests/persistence/test_mgr_methods.py index 0c75cf2d4..0182b7eea 100644 --- a/tests/unit_tests/persistence/test_mgr_methods.py +++ b/tests/unit_tests/persistence/test_mgr_methods.py @@ -50,8 +50,8 @@ class TestExecuteAsync: # Execute a simple select await mgr.execute_async(sqlalchemy.select(1)) - mock_conn.execute.assert_called_once() - mock_conn.commit.assert_called_once() + mock_conn.execute.assert_awaited_once() + mock_conn.commit.assert_awaited_once() @pytest.mark.asyncio async def test_execute_async_returns_result(self): diff --git a/tests/unit_tests/persistence/test_runner_state_migration.py b/tests/unit_tests/persistence/test_runner_state_migration.py index 82e12282a..f016d09b6 100644 --- a/tests/unit_tests/persistence/test_runner_state_migration.py +++ b/tests/unit_tests/persistence/test_runner_state_migration.py @@ -39,4 +39,6 @@ def test_two_populated_tables_fail_without_discarding_either(): with pytest.raises(RuntimeError, match='Both .* contain state'): migration.upgrade() assert set(sa.inspect(conn).get_table_names()) == {'agent_runner_state', 'runner_state'} + assert conn.execute(sa.text('SELECT id FROM agent_runner_state')).all() == [(1,)] + assert conn.execute(sa.text('SELECT id FROM runner_state')).all() == [(1,)] engine.dispose() diff --git a/tests/unit_tests/persistence/test_serialize_model.py b/tests/unit_tests/persistence/test_serialize_model.py index bbab59c67..e90b3d337 100644 --- a/tests/unit_tests/persistence/test_serialize_model.py +++ b/tests/unit_tests/persistence/test_serialize_model.py @@ -81,7 +81,7 @@ class TestSerializeModel: result = mgr.serialize_model(TestModel, instance) - assert '2024-01-15' in result['created_at'] + assert result['created_at'] == '2024-01-15T10:30:45+00:00' assert isinstance(result['created_at'], str) def test_serialize_none_datetime(self): diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py index 0c374f201..ee4c0b570 100644 --- a/tests/unit_tests/persistence/test_tenant_uow.py +++ b/tests/unit_tests/persistence/test_tenant_uow.py @@ -980,6 +980,18 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem _validate_scoped_statement_call((statement,), {}) +async def test_monitoring_json_casts_are_limited_to_journal_metadata() -> None: + from langbot.pkg.entity.persistence.agent_run import AgentRun + from langbot.pkg.entity.persistence.event_log import EventLog + + for column in (AgentRun.metadata_json, EventLog.metadata_json, EventLog.input_json): + statement = sa.select(sa.cast(column, sa.JSON)['status'].as_string()) + _validate_scoped_statement_call((statement,), {}) + for column in (AgentRun.authorization_json, sa.literal('{}')): + with pytest.raises(ScopedSessionTransactionError, match='casts'): + _validate_scoped_statement_call((sa.select(sa.cast(column, sa.JSON)),), {}) + + async def test_scoped_session_executes_sqlite_strftime() -> None: engine = create_async_engine('sqlite+aiosqlite:///:memory:') try: diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index 22d81e2b4..07567c1f7 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -88,6 +88,12 @@ class MockApplication: def _create_mock_persistence_manager(self): persistence_mgr = AsyncMock() persistence_mgr.execute_async = AsyncMock() + result = Mock(scalar_one_or_none=Mock(return_value=None)) + uow = AsyncMock() + uow.execute = AsyncMock(return_value=result) + boundary = AsyncMock() + boundary.__aenter__.return_value = uow + persistence_mgr.tenant_uow = Mock(return_value=boundary) return persistence_mgr def _create_mock_query_pool(self): diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 24141b3ec..274c6b523 100644 --- a/tests/unit_tests/pipeline/test_aggregator.py +++ b/tests/unit_tests/pipeline/test_aggregator.py @@ -465,8 +465,19 @@ class TestMessageAggregatorAddMessage: pipeline_uuid=None, # None -> disabled ) - # Should have called query_pool.add_query - assert app.query_pool.add_query.called + app.query_pool.add_query.assert_awaited_once_with( + execution_context=execution_context(), + bot_uuid='test-bot', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_event=event, + message_chain=chain, + adapter=adapter, + pipeline_uuid=None, + routed_by_rule=False, + variables=None, + ) @pytest.mark.asyncio async def test_enabled_buffers_message(self): @@ -505,8 +516,27 @@ class TestMessageAggregatorAddMessage: pipeline_uuid='test-pipeline', ) - # Should have buffered the message - assert len(agg.buffers) == 1 + context = execution_context(pipeline_uuid='test-pipeline') + key = aggregation_key(context, pipeline_uuid='test-pipeline') + assert list(agg.buffers) == [key] + assert agg.buffers[key].execution_context == context + assert len(agg.buffers[key].messages) == 1 + pending = agg.buffers[key].messages[0] + assert {key: value for key, value in vars(pending).items() if key != 'timestamp'} == { + 'execution_context': context, + 'bot_uuid': 'test-bot', + 'launcher_type': provider_session.LauncherTypes.PERSON, + 'launcher_id': 12345, + 'sender_id': 12345, + 'message_event': event, + 'message_chain': chain, + 'adapter': adapter, + 'pipeline_uuid': 'test-pipeline', + 'routed_by_rule': False, + 'variables': None, + } + app.query_pool.add_query.assert_not_awaited() + await agg.flush_all() @pytest.mark.asyncio async def test_control_variables_bypass_aggregation(self): @@ -560,19 +590,18 @@ class TestMessageAggregatorAddMessage: agg = aggregator.MessageAggregator(app) - chain = text_chain('hello') - event = friend_message_event(chain) adapter = mock_adapter() # Add messages up to MAX_BUFFER_MESSAGES for i in range(aggregator.MAX_BUFFER_MESSAGES): + chain = text_chain(f'message {i}') await agg.add_message( execution_context=execution_context(pipeline_uuid='test-pipeline'), bot_uuid='test-bot', launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345, sender_id=12345, - message_event=event, + message_event=friend_message_event(chain), message_chain=chain, adapter=adapter, pipeline_uuid='test-pipeline', @@ -588,6 +617,14 @@ class TestMessageAggregatorAddMessage: 'test-pipeline', ) assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0 + app.query_pool.add_query.assert_awaited_once() + forwarded = app.query_pool.add_query.call_args.kwargs + assert str(forwarded['message_chain']) == '\n'.join( + f'message {i}' for i in range(aggregator.MAX_BUFFER_MESSAGES) + ) + assert forwarded['execution_context'] == context + assert forwarded['bot_uuid'] == 'test-bot' + assert forwarded['pipeline_uuid'] == 'test-pipeline' class TestMessageAggregatorMerge: @@ -658,10 +695,7 @@ class TestMessageAggregatorMerge: merged = agg._merge_messages([pending1, pending2]) - # Should contain both messages with separator - merged_str = str(merged.message_chain) - assert 'hello' in merged_str - assert 'world' in merged_str + assert str(merged.message_chain) == 'hello\nworld' 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.""" @@ -760,7 +794,19 @@ class TestMessageAggregatorFlush: await agg._flush_buffer(key, context) - assert app.query_pool.add_query.called + app.query_pool.add_query.assert_awaited_once_with( + execution_context=context, + bot_uuid='test-bot', + launcher_type=provider_session.LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + message_event=event, + message_chain=chain, + adapter=adapter, + pipeline_uuid=None, + routed_by_rule=False, + variables=None, + ) assert key not in agg.buffers @pytest.mark.asyncio @@ -825,8 +871,8 @@ class TestMessageAggregatorFlushAll: app = make_aggregator_app() agg = aggregator.MessageAggregator(app) - chain = text_chain('hello') - event = friend_message_event(chain) + first_chain = text_chain('first') + second_chain = text_chain('second') adapter = mock_adapter() # Create two buffers @@ -836,8 +882,8 @@ class TestMessageAggregatorFlushAll: launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=12345, sender_id=12345, - message_event=event, - message_chain=chain, + message_event=friend_message_event(first_chain), + message_chain=first_chain, adapter=adapter, pipeline_uuid=None, ) @@ -848,8 +894,8 @@ class TestMessageAggregatorFlushAll: launcher_type=provider_session.LauncherTypes.PERSON, launcher_id=67890, sender_id=67890, - message_event=event, - message_chain=chain, + message_event=friend_message_event(second_chain), + message_chain=second_chain, adapter=adapter, pipeline_uuid=None, ) @@ -875,7 +921,11 @@ class TestMessageAggregatorFlushAll: # Both buffers should be flushed assert len(agg.buffers) == 0 - assert app.query_pool.add_query.call_count == 2 + assert app.query_pool.add_query.await_count == 2 + assert [ + (sent.kwargs['launcher_id'], str(sent.kwargs['message_chain']), sent.kwargs['execution_context']) + for sent in app.query_pool.add_query.await_args_list + ] == [(12345, 'first', context), (67890, 'second', context)] class TestMessageAggregatorWorkspaceIsolation: diff --git a/tests/unit_tests/pipeline/test_banwords.py b/tests/unit_tests/pipeline/test_banwords.py index 2dcaf2cf4..a33a0a2e1 100644 --- a/tests/unit_tests/pipeline/test_banwords.py +++ b/tests/unit_tests/pipeline/test_banwords.py @@ -82,7 +82,7 @@ async def test_invalid_sensitive_word_regex_still_blocks(): result = await filt.process(Mock(), 'any message') assert result.level == entities.ResultLevel.BLOCK - assert result.user_notice == '内容检查规则执行失败,请联系管理员' + assert result.user_notice == 'Content checking failed. Please contact the administrator.' assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower() @@ -97,7 +97,7 @@ async def test_oversized_word_list_is_blocked(): assert result.level == entities.ResultLevel.BLOCK assert result.replacement == '' - assert result.user_notice == '内容检查规则执行失败,请联系管理员' + assert result.user_notice == 'Content checking failed. Please contact the administrator.' assert 'at most 256 regex patterns are allowed' in result.console_notice.lower() diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py index 439c41491..ff39ad7ea 100644 --- a/tests/unit_tests/pipeline/test_chat_handler.py +++ b/tests/unit_tests/pipeline/test_chat_handler.py @@ -89,6 +89,25 @@ def set_runner(fake_app): return _set_runner +@pytest.fixture +def chat_session(): + from langbot_plugin.api.entities.builtin.provider.session import Session, Conversation, LauncherTypes + from langbot_plugin.api.entities.builtin.provider.prompt import Prompt + + return Session( + launcher_type=LauncherTypes.PERSON, + launcher_id=12345, + sender_id=12345, + bot_uuid='test-bot-uuid', + using_conversation=Conversation( + prompt=Prompt(name='default', messages=[]), + messages=[], + pipeline_uuid='test-pipeline-uuid', + bot_uuid='test-bot-uuid', + ), + ) + + # ============== CACHED LAZY IMPORTS ============== _chat_handler_module = None @@ -189,7 +208,7 @@ class TestChatMessageHandlerReal: async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner): """user_message_alter as string updates query.user_message.""" from tests.factories import text_query - from langbot_plugin.api.entities.builtin.provider.message import Message + from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement chat = get_chat_handler() @@ -200,7 +219,8 @@ class TestChatMessageHandlerReal: query = text_query('original') query.adapter = Mock() query.adapter.is_stream_output_supported = AsyncMock(return_value=False) - query.user_message = Message(role='user', content=[]) + query.user_message = Message(role='user', content=[ContentElement.from_text('original')]) + received_contents = [] class QuickRunner: name = 'local-agent' @@ -210,6 +230,7 @@ class TestChatMessageHandlerReal: self.config = config async def run(self, query): + received_contents.append(query.user_message.content) yield Message(role='assistant', content='ok') set_runner(QuickRunner) @@ -220,10 +241,14 @@ class TestChatMessageHandlerReal: async for result in handler.handle(query): results.append(result) - assert query.user_message.content is not None + expected_content = [ContentElement.from_text('altered text')] + assert query.user_message.content == expected_content + assert received_contents == [expected_content] @pytest.mark.asyncio - async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner): + async def test_adapter_without_stream_method_defaults_non_stream( + self, fake_app, mock_event_ctx, set_runner, chat_session + ): """Adapter without is_stream_output_supported defaults to non-stream.""" from tests.factories import text_query from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement @@ -237,6 +262,7 @@ class TestChatMessageHandlerReal: query = text_query('test') query.adapter = Mock(spec=[]) query.user_message = Message(role='user', content=[ContentElement.from_text('test')]) + query.session = chat_session class SingleRunner: name = 'local-agent' @@ -256,7 +282,9 @@ class TestChatMessageHandlerReal: async for result in handler.handle(query): results.append(result) - assert len(results) >= 1 + assert len(results) == 1 + assert results[0].result_type == get_entities().ResultType.CONTINUE + assert [(message.role, message.content) for message in query.resp_messages] == [('assistant', 'response')] @pytest.mark.usefixtures('mock_circular_import_chain') @@ -264,7 +292,58 @@ class TestChatHandlerStreaming: """Tests for streaming behavior.""" @pytest.mark.asyncio - async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner): + @pytest.mark.parametrize('with_tool_call', [False, True]) + async def test_terminal_delta_and_snapshot_close_card_once( + self, fake_app, mock_event_ctx, set_runner, with_tool_call + ): + from tests.factories import text_query + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain + + fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + query = text_query('stream test') + query.adapter = Mock() + query.adapter.is_stream_output_supported = AsyncMock(return_value=True) + query.adapter.create_message_card = AsyncMock() + attachments = MessageChain([File(url='https://example.com/file.txt', name='file.txt')]) + + class StreamRunner: + def __init__(self, app, config): + pass + + async def run(self, query): + if with_tool_call: + yield MessageChunk( + role='assistant', + is_final=True, + tool_calls=[ + {'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}} + ], + ) + yield MessageChunk(role='assistant', content='hello') + yield MessageChunk(role='assistant', content='hello world', is_final=True) + completed = Message(role='assistant', content='hello world') + completed.attachments = attachments + yield completed + + set_runner(StreamRunner) + handler = get_chat_handler().ChatMessageHandler(fake_app) + handler._ensure_conversation_for_history = AsyncMock() + delivered = [] + async for stage_result in handler.handle(query): + assert stage_result.result_type == get_entities().ResultType.CONTINUE + delivered.append(query.resp_messages[-1]) + assert [result.content for result in delivered if result.content] == ['hello', 'hello world'] + if with_tool_call: + assert delivered[0].tool_calls + assert not delivered[0].is_final + assert sum(result.is_final for result in delivered) == 1 + assert delivered[-1].attachments is attachments + assert len({result.resp_message_id for result in delivered}) == 1 + query.adapter.create_message_card.assert_awaited_once() + + @pytest.mark.asyncio + async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner, chat_session): """Streaming produces multiple results.""" from tests.factories import text_query from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk @@ -279,6 +358,7 @@ class TestChatHandlerStreaming: query.adapter.is_stream_output_supported = AsyncMock(return_value=True) query.adapter.create_message_card = AsyncMock() query.user_message = Message(role='user', content=[ContentElement.from_text('test')]) + query.session = chat_session class StreamRunner: name = 'local-agent' @@ -289,17 +369,24 @@ class TestChatHandlerStreaming: async def run(self, query): yield MessageChunk(role='assistant', content='Hello', is_final=False) - yield MessageChunk(role='assistant', content=' World', is_final=True) + yield MessageChunk(role='assistant', content='Hello World', is_final=True) set_runner(StreamRunner) handler = chat.ChatMessageHandler(fake_app) results = [] + delivered = [] async for result in handler.handle(query): results.append(result) + delivered.append(query.resp_messages[-1]) - assert len(results) >= 1 + assert len(results) == 2 + assert all(result.result_type == get_entities().ResultType.CONTINUE for result in results) + assert [chunk.content for chunk in delivered] == ['Hello', 'Hello World'] + assert [chunk.is_final for chunk in delivered] == [False, True] + assert len({chunk.resp_message_id for chunk in delivered}) == 1 + query.adapter.create_message_card.assert_awaited_once() @pytest.mark.usefixtures('mock_circular_import_chain') @@ -485,7 +572,7 @@ class TestChatHandlerHelper: chat = get_chat_handler() handler = chat.ChatMessageHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result + assert result == 'first line...' def test_response_size_limit_uses_instance_config(self, fake_app): from langbot_plugin.api.entities.builtin.provider.message import Message diff --git a/tests/unit_tests/pipeline/test_chat_handler_logging.py b/tests/unit_tests/pipeline/test_chat_handler_logging.py index 6ae85558f..83769bfae 100644 --- a/tests/unit_tests/pipeline/test_chat_handler_logging.py +++ b/tests/unit_tests/pipeline/test_chat_handler_logging.py @@ -2,14 +2,11 @@ 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, -) +# Initialize the application import graph before importing a leaf handler. This +# matches normal startup and avoids handler -> app -> handler partial imports. +from langbot.pkg.core import app # noqa: F401 from langbot.pkg.pipeline.process.handler import MessageHandler # noqa: E402 diff --git a/tests/unit_tests/pipeline/test_command_handler.py b/tests/unit_tests/pipeline/test_command_handler.py index bc5d39481..2f105a384 100644 --- a/tests/unit_tests/pipeline/test_command_handler.py +++ b/tests/unit_tests/pipeline/test_command_handler.py @@ -367,6 +367,8 @@ class TestCommandHandlerReal: assert len(msg.content) == 2 assert msg.content[0].type == 'text' assert msg.content[1].type == 'image_url' + assert msg.content[0].text == 'Here is the image:' + assert msg.content[1].image_url.url == 'https://example.com/image.png' @pytest.mark.asyncio async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory): @@ -410,4 +412,4 @@ class TestCommandHandlerHelper: command = get_command_handler() handler = command.CommandHandler(fake_app) result = handler.cut_str('first line\nsecond line') - assert '...' in result + assert result == 'first line...' diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index d054b58ee..b3ea09e17 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -1,4 +1,4 @@ -"""Behavioral tests for the pure, fail-closed legacy migration planner.""" +"""Behavioral tests for the pure legacy migration planner (relaxed v5 semantics).""" import copy import importlib @@ -11,15 +11,15 @@ import pytest FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text()) TARGETS = { - 'local-agent': ('LocalAgent', '0.1.10', None), - 'dify-service-api': ('DifyAgent', '0.1.10', None), - 'coze-api': ('CozeAgent', '0.1.10', None), - 'dashscope-app-api': ('DashScopeAgent', '0.1.10', None), - 'n8n-service-api': ('N8nAgent', '0.1.10', None), - 'langflow-api': ('LangflowAgent', '0.1.10', None), - 'deerflow-api': ('DeerFlowAgent', '0.1.10', None), - 'tbox-app-api': ('TboxAgent', '0.1.8', None), - 'weknora-api': ('WeKnoraAgent', '0.1.10', None), + 'local-agent': ('LocalAgent', '0.2.1', None), + 'dify-service-api': ('DifyAgent', '0.2.3', None), + 'coze-api': ('CozeAgent', '0.2.0', None), + 'dashscope-app-api': ('DashScopeAgent', '0.2.2', None), + 'n8n-service-api': ('N8nAgent', '0.2.0', None), + 'langflow-api': ('LangflowAgent', '0.2.2', None), + 'deerflow-api': ('DeerFlowAgent', '0.2.1', None), + 'tbox-app-api': ('TboxAgent', '0.2.1', None), + 'weknora-api': ('WeKnoraAgent', '0.2.1', None), } @@ -47,6 +47,21 @@ def assert_block(result, code, field=None): assert expected in result['blockers'] +def assert_repaired(result, code, field=None): + """Relaxed planner (v5): non-critical problems are repaired and reported as warnings.""" + assert result['state'] == 'ready', result['blockers'] + assert result['blockers'] == [] + assert result['config'] is not None + codes = {(w['code'], w.get('field')) for w in result['warnings']} + if field is None: + assert code in {c for c, _ in codes} + else: + assert (code, field) in codes + public = {key: value for key, value in result.items() if key != 'config'} + assert 'SECRET' not in json.dumps(public) + assert 'secret-' not in json.dumps(public) + + @pytest.mark.parametrize('runner', TARGETS) def test_recognizes_all_nine_exact_official_identities(runner): result = plan(source_for(runner)) @@ -140,11 +155,22 @@ def test_malformed_envelopes_are_safe_blockers(field, value): assert_block(plan(source), 'invalid_type', 'ai' if field == 'ai' else 'ai.runner') -@pytest.mark.parametrize('current', [None, '', False, {}, 'plugin:langbot-team/DeerFlowAgent/default']) -def test_mixed_selections_block_even_empty_current_id(current): +@pytest.mark.parametrize('current', [None, '', False, {}]) +def test_mixed_selection_with_unusable_current_id_migrates_legacy(current): source = source_for() source['ai']['runner']['id'] = current - assert_block(plan(source), 'mixed_runner_selection', 'ai.runner') + result = plan(source) + assert_repaired(result, 'migration.mixed_runner_selection', 'ai.runner') + assert result['config']['ai']['runner']['id'] == 'plugin:langbot-team/DeerFlowAgent/default' + assert 'runner' not in result['config']['ai']['runner'] + + +def test_mixed_selection_prefers_valid_current_id(): + source = source_for() + source['ai']['runner']['id'] = 'plugin:langbot-team/DeerFlowAgent/default' + result = plan(source) + assert result['state'] == 'already_current' + assert {'code': 'migration.mixed_runner_selection', 'field': 'ai.runner'} in result['warnings'] @pytest.mark.parametrize('value', [None, False, 0, '', [], 'secret']) @@ -154,33 +180,59 @@ def test_malformed_active_runner_section_is_not_defaulted(value): assert_block(plan(source), 'invalid_type', 'ai.deerflow-api') -def test_existing_runner_config_is_not_overwritten(): +def test_existing_runner_config_is_replaced_with_warning(): source = source_for() - source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'value'}} - assert_block(plan(source), 'mixed_runner_config', 'ai.runner_config') - - -def test_unknown_selection_is_not_echoed_as_public_legacy_id(): - source = source_for() - source['ai']['runner']['runner'] = 'secret-value' + source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'SECRET-value'}} result = plan(source) - assert result['state'] == 'not_legacy' - assert 'secret-value' not in json.dumps(result) + assert_repaired(result, 'migration.runner_config_replaced', 'ai.runner_config') + assert list(result['config']['ai']['runner_config']) == [result['target_runner_id']] + + +@pytest.mark.parametrize('name', ['my-runner', 'custom_runner']) +def test_custom_legacy_runner_is_unsupported_not_blocked(name): + source = source_for() + source['ai']['runner']['runner'] = name + result = plan(source) + assert result['state'] == 'unsupported_runner' + assert result['config'] is None + assert result['blockers'] == [] + assert result['changed_paths'] == [] + assert {'code': 'legacy.custom_runner_unsupported', 'field': 'ai.runner.runner'} in result['warnings'] + + +def test_custom_runner_diagnostics_never_echo_section_values(): + source = source_for() + source['ai']['runner']['runner'] = 'my-runner' + source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'} + result = plan(source) + assert result['state'] == 'unsupported_runner' + assert 'SECRET' not in json.dumps(result) + + +def test_leftover_custom_section_does_not_block_builtin_migration(): + source = source_for() + source['ai']['my-runner'] = {'api-key': 'SECRET-KEY'} + result = plan(source) + assert_repaired(result, 'migration.unknown_section_archived', 'ai') + assert 'my-runner' not in result['config']['ai'] @pytest.mark.parametrize('value', [None, False, -1, 1.5, '0']) -def test_expiry_is_strict_nonnegative_integer(value): +def test_malformed_expiry_is_defaulted(value): source = source_for() source['ai']['runner']['expire-time'] = value - assert_block(plan(source), 'invalid_expiry', 'ai.runner.expire-time') + result = plan(source) + assert result['state'] == 'ready' + assert result['config']['ai']['runner']['expire-time'] == 0 -def test_unknown_active_field_name_and_value_never_leak(): +def test_unknown_active_field_is_dropped_without_leaking(): source = source_for() source['ai']['deerflow-api']['secret-field-name'] = 'secret-field-value' result = plan(source) - assert_block(result, 'unknown_field', 'ai.deerflow-api') - assert 'secret-field' not in json.dumps(result) + assert_repaired(result, 'migration.unknown_field_dropped', 'ai.deerflow-api') + assert 'secret-field' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) + assert 'secret-field-name' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize('value', [float('nan'), float('inf'), {1: 'secret'}, ('tuple',), b'secret']) @@ -198,10 +250,12 @@ def test_cyclic_input_blocks_without_recursion_error(): @pytest.mark.parametrize('runner', TARGETS) @pytest.mark.parametrize('value', [None, False, 0, '', [], {}]) -def test_unknown_active_keys_block_even_when_falsey(runner, value): +def test_unknown_active_keys_are_dropped_even_when_falsey(runner, value): source = source_for(runner) source['ai'][runner]['secret-unknown-key'] = value - assert_block(plan(source), 'unknown_field', f'ai.{runner}') + result = plan(source) + assert_repaired(result, 'migration.unknown_field_dropped', f'ai.{runner}') + assert 'secret-unknown-key' not in selected_config(result) @pytest.mark.parametrize('runner', TARGETS) @@ -217,8 +271,9 @@ def test_unknown_active_envelope_keys_are_not_echoed(container): target = source['ai'] if container == 'ai' else source['ai']['runner'] target['secret-unknown-key'] = 'secret-value' result = plan(source) - assert_block(result, 'unknown_field', 'ai' if container == 'ai' else 'ai.runner') - assert 'secret-' not in json.dumps(result) + code = 'migration.unknown_section_archived' if container == 'ai' else 'migration.unknown_field_dropped' + assert_repaired(result, code, 'ai' if container == 'ai' else 'ai.runner') + assert 'secret-' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize( @@ -237,17 +292,29 @@ def test_unknown_active_envelope_keys_are_not_echoed(container): {'mcp_resources': [False]}, ], ) -def test_malformed_extension_preferences_block_without_expanding_access(preferences): +def test_malformed_extension_preferences_are_ignored_without_expanding_access(preferences): result = plan(source_for(), preferences) - assert result['state'] == 'blocked' - assert any(item['code'] == 'invalid_extension_preferences' for item in result['blockers']) - assert 'secret' not in json.dumps(result) + assert result['state'] == 'ready' + assert any(item['code'] == 'invalid_extension_preferences' for item in result['warnings']) + assert result['extensions_preferences'] is None + assert 'secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) -def test_explicit_plugin_denylist_is_not_silently_broadened(): - preferences = {'enable_all_plugins': False, 'plugins': []} - assert_block(plan(source_for(), preferences), 'extensions.runner_excluded', 'extensions_preferences.plugins') - assert preferences == {'enable_all_plugins': False, 'plugins': []} +def test_explicit_plugin_allowlist_gets_only_the_target_runner_added(): + preferences = {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]} + result = plan(source_for(), preferences) + assert_repaired(result, 'extensions.runner_added', 'extensions_preferences.plugins') + assert result['extensions_preferences'] == { + 'enable_all_plugins': False, + 'plugins': [{'author': 'other', 'name': 'Plugin'}, {'author': 'langbot-team', 'name': 'DeerFlowAgent'}], + } + assert preferences == {'enable_all_plugins': False, 'plugins': [{'author': 'other', 'name': 'Plugin'}]} + + +def test_enable_all_plugins_needs_no_extension_patch(): + result = plan(source_for(), {'enable_all_plugins': True}) + assert result['state'] == 'ready' + assert result['extensions_preferences'] is None @pytest.mark.parametrize('field', ['api-key', 'auth-header']) @@ -274,29 +341,50 @@ def test_deerflow_option_trim_drift_is_not_repaired(field, value): @pytest.mark.parametrize('field', ['thinking-enabled', 'plan-mode', 'subagent-enabled']) -@pytest.mark.parametrize('value', [None, 0, 1, 'false', []]) -def test_deerflow_requires_real_booleans(field, value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, False, 'migration.invalid_value_defaulted'), + ([], False, 'migration.invalid_value_defaulted'), + (0, False, 'migration.value_coerced'), + (1, True, 'migration.value_coerced'), + ('false', False, 'migration.value_coerced'), + ('true', True, 'migration.value_coerced'), + ], +) +def test_deerflow_booleans_are_coerced_or_defaulted(field, value, expected, code): source = source_for() source['ai']['deerflow-api'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + result = plan(source) + assert_repaired(result, code, f'ai.deerflow-api.{field}') + assert selected_config(result)[field] is expected -@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) -@pytest.mark.parametrize('value', [None, False, 1.5, '3']) -def test_deerflow_requires_real_integers_without_coercion(field, value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, 300, 'migration.invalid_value_defaulted'), + (False, 300, 'migration.invalid_value_defaulted'), + (1.5, 300, 'migration.invalid_value_defaulted'), + ('3', 3, 'migration.value_coerced'), + (0, 300, 'migration.invalid_value_defaulted'), + (-1, 300, 'migration.invalid_value_defaulted'), + ], +) +def test_deerflow_timeout_is_coerced_or_defaulted(value, expected, code): source = source_for() - source['ai']['deerflow-api'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}') + source['ai']['deerflow-api']['timeout'] = value + result = plan(source) + assert_repaired(result, code, 'ai.deerflow-api.timeout') + assert selected_config(result)['timeout'] == expected -@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit']) +@pytest.mark.parametrize('field', ['max-concurrent-subagents', 'recursion-limit']) @pytest.mark.parametrize('value', [0, -1, 1]) def test_deerflow_does_not_invent_numeric_clamps(field, value): source = source_for() source['ai']['deerflow-api'][field] = value - result = plan(source) - assert result['state'] == 'ready' - assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value + assert selected_config(plan(source))[field] == value @pytest.mark.parametrize('value', [None, False, 0, [], '']) @@ -333,7 +421,7 @@ def test_deerflow_native_runtime_defaults_are_materialized_not_ui_defaults(): 'mcp-resources': [], 'mcp-resource-agent-read-enabled': False, } - assert 'expire-time' not in result['config']['ai']['runner'] + assert result['config']['ai']['runner']['expire-time'] == 0 def test_deerflow_missing_endpoint_blocks_instead_of_using_schema_localhost(): @@ -361,11 +449,21 @@ def test_ready_public_projection_contains_no_secrets_or_credentials_in_urls(): assert 'SECRET' not in json.dumps(public) -@pytest.mark.parametrize('value', [None, 0, 'false']) -def test_remove_think_flag_is_not_coerced(value): +@pytest.mark.parametrize( + 'value,expected,code', + [ + (None, False, 'migration.invalid_value_defaulted'), + (0, False, 'migration.value_coerced'), + ('false', False, 'migration.value_coerced'), + ('true', True, 'migration.value_coerced'), + ], +) +def test_remove_think_flag_is_coerced_or_defaulted(value, expected, code): source = source_for('tbox-app-api') source['output']['misc']['remove-think'] = value - assert_block(plan(source), 'invalid_type', 'output.misc.remove-think') + result = plan(source) + assert_repaired(result, code, 'output.misc.remove-think') + assert selected_config(result)['remove-think'] is expected @pytest.mark.parametrize( @@ -401,83 +499,130 @@ def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds): @pytest.mark.parametrize( - 'field,value,code', + 'field,value,code,expected', [ ( 'model', {'primary': 'model', 'fallbacks': [], 'reasoning': {'SECRET-model': 'invalid-level'}}, - 'local.reasoning_value', + 'local.reasoning_dropped', + {'primary': 'model', 'fallbacks': [], 'reasoning': {}}, + ), + ( + 'prompt', + [{'role': 'system', 'content': 42}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'You are a helpful assistant.'}], + ), + ( + 'prompt', + [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'text'}], + ), + ( + 'prompt', + [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], + 'local.prompt_normalized', + [{'role': 'system', 'content': 'You are a helpful assistant.'}], ), - ('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'), - ('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'), - ('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'), ], ) -def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code): +def test_local_unsupported_behaviors_are_repaired_without_leaking(field, value, code, expected): source = source_for('local-agent') source['ai']['local-agent'][field] = value result = plan(source) - assert_block(result, code, f'ai.local-agent.{field}') - assert 'SECRET' not in json.dumps(result) + assert_repaired(result, code, f'ai.local-agent.{field}' + ('.reasoning' if field == 'model' else '')) + assert selected_config(result)[field] == expected + assert 'SECRET' not in json.dumps(result['config']['ai']) @pytest.mark.parametrize( - 'field,value', + 'field,value,expected', [ - ('enable-all-tools', None), - ('enable-all-tools', 0), - ('tools', None), - ('tools', [42]), - ('mcp-resources', None), - ('mcp-resources', [False]), - ('mcp-resource-agent-read-enabled', 'false'), + ('enable-all-tools', None, True), + ('enable-all-tools', 0, False), + ('tools', None, []), + ('tools', [42], []), + ('mcp-resources', None, []), + ('mcp-resources', [False], []), + ('mcp-resource-agent-read-enabled', 'false', False), ], ) -def test_local_host_policy_malformed_values_block(field, value): +def test_local_host_policy_malformed_values_are_repaired(field, value, expected): source = source_for('local-agent') source['ai']['local-agent'][field] = value - assert_block(plan(source), 'invalid_type', f'ai.local-agent.{field}') + result = plan(source) + assert result['state'] == 'ready' + assert selected_config(result)[field] == expected + codes = {(w['code'], w['field']) for w in result['warnings']} + assert codes & { + ('migration.value_coerced', f'ai.local-agent.{field}'), + ('migration.invalid_value_defaulted', f'ai.local-agent.{field}'), + } @pytest.mark.parametrize( 'runner,field', [ ('dify-service-api', 'api-key'), - ('dify-service-api', 'base-prompt'), - ('coze-api', 'timeout'), - ('coze-api', 'api-base'), ('langflow-api', 'flow-id'), ('n8n-service-api', 'webhook-url'), ('tbox-app-api', 'app-id'), - ('weknora-api', 'app-type'), ], ) -def test_defaultless_native_fields_are_not_filled_from_target_schema(runner, field): +def test_missing_required_credentials_and_endpoints_still_block(runner, field): source = source_for(runner) del source['ai'][runner][field] assert_block(plan(source), 'missing_field', f'ai.{runner}.{field}') -@pytest.mark.parametrize('value', [None, False, 0, [], {}]) -@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')]) +@pytest.mark.parametrize( + 'runner,field', + [ + ('dify-service-api', 'base-prompt'), + ('coze-api', 'timeout'), + ('coze-api', 'api-base'), + ('weknora-api', 'app-type'), + ], +) +def test_missing_optional_fields_are_filled_with_410_defaults(runner, field): + source = source_for(runner) + del source['ai'][runner][field] + result = plan(source) + assert_repaired(result, 'migration.missing_default_filled', f'ai.{runner}.{field}') + + +@pytest.mark.parametrize('value', [None, False, [], {}]) +@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api', 'deerflow-api')]) def test_external_api_keys_require_strings_even_if_target_coerces(runner, value): source = source_for(runner) source['ai'][runner]['api-key'] = value assert_block(plan(source), 'invalid_type', f'ai.{runner}.api-key') +@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')]) +def test_numeric_external_api_key_is_stringified(runner): + source = source_for(runner) + source['ai'][runner]['api-key'] = 0 + result = plan(source) + assert_repaired(result, 'migration.value_coerced', f'ai.{runner}.api-key') + assert selected_config(result)['api-key'] == '0' + + @pytest.mark.parametrize( - 'section', + 'section,expected', [ - {'auto_save_history': False, 'auto-save-history': True}, - {'auto_save_history': True, 'auto-save-history': False}, + ({'auto_save_history': False, 'auto-save-history': True}, True), + ({'auto_save_history': True, 'auto-save-history': False}, False), ], ) -def test_coze_actual_underscore_history_key_missing_null_or_conflict_blocks(section): +def test_coze_history_alias_conflict_uses_canonical_field(section, expected): source = source_for('coze-api') source['ai']['coze-api'].pop('auto_save_history') source['ai']['coze-api'].update(section) - assert_block(plan(source), 'coze.history_alias', 'ai.coze-api.auto_save_history') + result = plan(source) + assert_repaired(result, 'migration.alias_conflict_resolved', 'ai.coze-api.auto_save_history') + assert selected_config(result)['auto-save-history'] is expected @pytest.mark.parametrize('value', [False, True]) @@ -497,27 +642,36 @@ def test_coze_custom_endpoint_not_replaced_with_schema_region(): assert 'secret-' not in json.dumps(result) -@pytest.mark.parametrize('underscore,hyphen', [('one', 'two')]) -def test_dashscope_seed_hyphen_is_not_actual_native_runtime_key(underscore, hyphen): +def test_dashscope_seed_alias_conflict_is_resolved_with_warning(): source = source_for('dashscope-app-api') section = source['ai']['dashscope-app-api'] section.pop('references_quote') - if underscore is not None: - section['references_quote'] = underscore - section['references-quote'] = hyphen - assert_block(plan(source), 'dashscope.references_alias', 'ai.dashscope-app-api.references_quote') + section['references_quote'] = 'one' + section['references-quote'] = 'two' + assert_repaired(plan(source), 'migration.alias_conflict_resolved', 'ai.dashscope-app-api.references-quote') @pytest.mark.parametrize('axis', ['input', 'output']) -@pytest.mark.parametrize('underscore,hyphen', [(None, 'text'), ('chat', 'text'), ('text', 'chat')]) -def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscore, hyphen): +@pytest.mark.parametrize( + 'underscore,hyphen,code', + [ + (None, 'text', 'migration.alias_repaired'), + ('chat', 'text', 'migration.alias_conflict_resolved'), + ('text', 'chat', 'migration.alias_conflict_resolved'), + ], +) +def test_langflow_io_aliases_use_canonical_hyphen_field(axis, underscore, hyphen, code): source = source_for('langflow-api') section = source['ai']['langflow-api'] section.pop(f'{axis}_type') if underscore is not None: section[f'{axis}_type'] = underscore section[f'{axis}-type'] = hyphen - assert_block(plan(source), 'langflow.io_alias', f'ai.langflow-api.{axis}_type') + result = plan(source) + assert result['state'] == 'ready' + assert selected_config(result)[f'{axis}-type'] == hyphen + if underscore is not None: + assert {'code': code, 'field': f'ai.langflow-api.{axis}_type'} in result['warnings'] @pytest.mark.parametrize( @@ -537,12 +691,13 @@ def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscor 0, ], ) -def test_langflow_tweaks_require_strict_object_json_without_fallback_loss(raw): +def test_invalid_langflow_tweaks_are_defaulted_without_leaking(raw): source = source_for('langflow-api') source['ai']['langflow-api']['tweaks'] = raw result = plan(source) - assert_block(result, 'langflow.invalid_tweaks', 'ai.langflow-api.tweaks') - assert 'broken-secret' not in json.dumps(result) + assert_repaired(result, 'langflow.tweaks_invalid_defaulted', 'ai.langflow-api.tweaks') + assert selected_config(result)['tweaks'] == {} + assert 'broken-secret' not in json.dumps({k: v for k, v in result.items() if k != 'config'}) @pytest.mark.parametrize('raw', [None, '', '{}', '{"nested":{"token":"SECRET","values":[null,false,0]}}']) @@ -616,19 +771,20 @@ def test_n8n_secrets_remain_raw_and_not_public_even_for_inactive_modes(mode): @pytest.mark.parametrize( - 'runner,field,value', + 'runner,field,value,expected', [ - ('dify-service-api', 'app-type', 'unknown'), - ('dashscope-app-api', 'app-type', 'chat'), - ('weknora-api', 'app-type', 'workflow'), - ('n8n-service-api', 'auth-type', 'SECRET-unknown'), + ('dify-service-api', 'app-type', 'unknown', 'chat'), + ('dashscope-app-api', 'app-type', 'chat', 'agent'), + ('weknora-api', 'app-type', 'workflow', 'chat'), + ('n8n-service-api', 'auth-type', 'SECRET-unknown', 'none'), ], ) -def test_invalid_enum_values_are_not_echoed(runner, field, value): +def test_invalid_enum_values_are_defaulted_and_not_echoed(runner, field, value, expected): source = source_for(runner) source['ai'][runner][field] = value result = plan(source) - assert_block(result, 'invalid_value', f'ai.{runner}.{field}') + assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.{field}') + assert selected_config(result)[field] == expected assert 'SECRET' not in json.dumps(result) @@ -818,7 +974,7 @@ def test_dashscope_unambiguous_seed_alias_is_repaired(value): @pytest.mark.parametrize('mode', ['agent', 'chat']) @pytest.mark.parametrize('value', ['ABSENT', None, '', ' ', 'saved-agent']) -def test_weknora_agent_absence_is_not_null_or_empty(mode, value): +def test_weknora_agent_absence_is_defaulted(mode, value): source = source_for('weknora-api') section = source['ai']['weknora-api'] section['app-type'] = mode @@ -826,9 +982,8 @@ def test_weknora_agent_absence_is_not_null_or_empty(mode, value): if value != 'ABSENT': section['agent-id'] = value selected = selected_config(plan(source)) - assert selected['agent-id'] == ( - ('builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning') if value == 'ABSENT' else value - ) + default = 'builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning' + assert selected['agent-id'] == (default if value in ('ABSENT', None) else value) assert selected['knowledge-base-ids'] == [] assert selected['knowledge-bases'] == [] @@ -914,18 +1069,22 @@ def test_n8n_ignore_and_native_latin1_are_selected(): assert selected['basic-encoding'] == 'latin1' -def test_weknora_blank_remote_kb_is_rejected(): +def test_weknora_blank_remote_kb_is_dropped(): source = source_for('weknora-api') source['ai']['weknora-api']['knowledge-base-ids'] = [' '] - assert_block(plan(source), 'invalid_value', 'ai.weknora-api.knowledge-base-ids') + result = plan(source) + assert_repaired(result, 'migration.value_coerced', 'ai.weknora-api.knowledge-base-ids') + assert selected_config(result)['knowledge-base-ids'] == [] @pytest.mark.parametrize('runner', ['coze-api', 'n8n-service-api', 'weknora-api']) @pytest.mark.parametrize('value', [0, -1]) -def test_external_target_requires_positive_timeout(runner, value): +def test_nonpositive_external_timeout_is_defaulted(runner, value): source = source_for(runner) source['ai'][runner]['timeout'] = value - assert_block(plan(source), 'invalid_value', f'ai.{runner}.timeout') + result = plan(source) + assert_repaired(result, 'migration.invalid_value_defaulted', f'ai.{runner}.timeout') + assert selected_config(result)['timeout'] == 120 @pytest.mark.parametrize( @@ -975,6 +1134,7 @@ def test_ready_all9_nested_candidates_are_detached_and_no_io(runner, monkeypatch 'target_runner_id', 'target_plugin', 'config', + 'extensions_preferences', 'changed_paths', 'blockers', 'warnings', @@ -989,13 +1149,14 @@ def test_empty_config_is_not_legacy(): 'target_runner_id': None, 'target_plugin': None, 'config': None, + 'extensions_preferences': None, 'changed_paths': [], 'blockers': [], 'warnings': [], } -def test_default_local_agent_blocks_instead_of_inventing_round_translation(): +def test_minimal_local_agent_is_completed_with_defaults(): source = { 'ai': { 'runner': {'runner': 'local-agent', 'expire-time': 0}, @@ -1004,15 +1165,25 @@ def test_default_local_agent_blocks_instead_of_inventing_round_translation(): } original = copy.deepcopy(source) result = planner().plan_legacy_pipeline(source) - assert planner().PLANNER_VERSION == '4' - assert result['state'] == 'blocked' + assert planner().PLANNER_VERSION == '5' assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default' - assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.10'} - assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers'] - assert result['config'] is None + assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.2.1'} + selected = selected_config(result) + assert selected['model'] == {'primary': 'model-id', 'fallbacks': [], 'reasoning': {}} + assert 'max-round' not in selected + assert selected['prompt'] == [{'role': 'system', 'content': 'You are a helpful assistant.'}] + assert {'code': 'migration.new_defaults', 'field': 'ai.local-agent'} in result['warnings'] assert source == original +def test_local_agent_without_primary_model_still_blocks(): + source = source_for('local-agent') + del source['ai']['local-agent']['model'] + result = plan(source) + assert result['state'] == 'blocked' + assert result['config'] is None + + @pytest.mark.parametrize('template', ['{global}', '{launcher_type}_{launcher_id}', '{sender_id}', '{project}']) def test_local_box_reuse_templates_are_preserved_for_plugin(template): source = source_for('local-agent') @@ -1025,8 +1196,11 @@ def test_local_box_reuse_templates_are_preserved_for_plugin(template): @pytest.mark.parametrize( 'template', ['', ' ', '{}', '{0}', '{sender_id', '{sender_id!r}', '{query_id:04}', '{actor.id}', '{actor[id]}'] ) -def test_invalid_box_templates_are_reported_before_migration(template): +def test_invalid_box_templates_are_defaulted(template): source = source_for('local-agent') source['ai']['local-agent']['box-session-id-template'] = template result = plan(source) - assert_block(result, 'local.box_template_invalid', 'ai.local-agent.box-session-id-template') + assert_repaired(result, 'local.box_template_defaulted', 'ai.local-agent.box-session-id-template') + assert selected_config(result)['box-session-id-template'] == '{launcher_type}_{launcher_id}' + + diff --git a/tests/unit_tests/pipeline/test_longtext.py b/tests/unit_tests/pipeline/test_longtext.py index 3a693276f..19f14aacd 100644 --- a/tests/unit_tests/pipeline/test_longtext.py +++ b/tests/unit_tests/pipeline/test_longtext.py @@ -13,6 +13,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, @@ -73,7 +74,7 @@ class TestLongTextProcessStageInit: async def test_initialize_forward_strategy(self): """Initialize with strategy='forward' should use ForwardComponentStrategy.""" longtext = get_longtext_module() - strategy = get_strategy_module() + forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward') app = FakeApp() stage = longtext.LongTextProcessStage(app) @@ -82,8 +83,7 @@ class TestLongTextProcessStageInit: await stage.initialize(pipeline_config) - assert stage.strategy_impl is not None - assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) + assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy) @pytest.mark.asyncio async def test_initialize_unknown_strategy_raises(self): @@ -340,10 +340,11 @@ class TestLongTextProcessStageImageStrategy: """Tests for image strategy handling (requires PIL/font).""" @pytest.mark.asyncio - async def test_image_strategy_missing_font_fallback(self): + async def test_image_strategy_missing_font_fallback(self, monkeypatch): """Missing font should fallback to forward strategy.""" longtext = get_longtext_module() - strategy = get_strategy_module() + forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward') + monkeypatch.setattr(longtext, 'os', SimpleNamespace(name='posix', path=SimpleNamespace(exists=lambda _: False))) app = FakeApp() stage = longtext.LongTextProcessStage(app) @@ -354,6 +355,5 @@ class TestLongTextProcessStageImageStrategy: # On non-Windows without font, should fallback to forward await stage.initialize(pipeline_config) - # Should have initialized (possibly with fallback strategy) - if stage.strategy_impl is not None: - assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) + assert pipeline_config['output']['long-text-processing']['strategy'] == 'forward' + assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy) diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py index f6189633d..547149007 100644 --- a/tests/unit_tests/pipeline/test_migration_current_shape.py +++ b/tests/unit_tests/pipeline/test_migration_current_shape.py @@ -57,11 +57,13 @@ def test_box_reuse_template_is_preserved_for_runner(template): assert result['config']['ai']['runner_config'][result['target_runner_id']]['box-session-id-template'] == template -def test_empty_box_template_requires_correction(): +def test_empty_box_template_is_defaulted_with_warning(): source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}} source['ai']['local-agent']['box-session-id-template'] = '' result = plan_legacy_pipeline(source) - assert result['state'] == 'blocked' - assert {'code': 'local.box_template_invalid', 'field': 'ai.local-agent.box-session-id-template'} in result[ - 'blockers' + assert result['state'] == 'ready' + selected = result['config']['ai']['runner_config'][result['target_runner_id']] + assert selected['box-session-id-template'] == '{launcher_type}_{launcher_id}' + assert {'code': 'local.box_template_defaulted', 'field': 'ai.local-agent.box-session-id-template'} in result[ + 'warnings' ] diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 46cccadad..1b64e2ca3 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -190,6 +190,7 @@ class TestPreProcessorNormalText: assert result.new_query.user_message is not None assert result.new_query.user_message.role == 'user' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [('text', 'test message')] class TestPreProcessorEmptyMessage: @@ -234,7 +235,8 @@ class TestPreProcessorImageSegment: """Tests for image segment handling.""" @pytest.mark.asyncio - async def test_image_with_vision_model(self): + @pytest.mark.parametrize('image_source', ['base64', 'url']) + async def test_image_with_vision_model(self, image_source): """Image should be included when model supports vision.""" preproc = get_preproc_module() @@ -253,6 +255,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + attach_runner_descriptor(app) app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) mock_event_ctx = Mock() @@ -260,15 +263,20 @@ class TestPreProcessorImageSegment: app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) stage = preproc.PreProcessor(app) - # Image query with base64 + # Exercise both supported image transports. 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'), + platform_message.Image( + **( + {'base64': 'data:image/png;base64,abc123'} + if image_source == 'base64' + else {'url': 'https://cdn.example/image.png'} + ) + ), ] ) query.message_chain = chain @@ -276,8 +284,23 @@ class TestPreProcessorImageSegment: result = await stage.process(query, 'PreProcessor') assert result.result_type == preproc.entities.ResultType.CONTINUE - # User message should have content - assert result.new_query.user_message.content is not None + assert result.new_query.use_llm_model_uuid == 'vision-model' + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + from langbot_plugin.api.entities.builtin.provider.message import ContentElement + + expected_image = ( + ContentElement.from_image_base64('data:image/png;base64,abc123') + if image_source == 'base64' + else ContentElement.from_image_url('https://cdn.example/image.png') + ) + assert result.new_query.user_message.content == [ContentElement.from_text('look at this'), expected_image] + contents = [part.model_dump() for part in result.new_query.user_message.content] + attachments = QueryEntryAdapter._build_attachments(result.new_query, contents) + assert len(attachments) == 1 + if image_source == 'url': + assert attachments[0]['url'] == 'https://cdn.example/image.png' + else: + assert attachments[0]['content'] == 'data:image/png;base64,abc123' @pytest.mark.asyncio async def test_image_without_vision_model(self): @@ -299,6 +322,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + attach_runner_descriptor(app) app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) mock_event_ctx = Mock() @@ -311,6 +335,12 @@ class TestPreProcessorImageSegment: result = await stage.process(query, 'PreProcessor') assert result.result_type == preproc.entities.ResultType.CONTINUE + assert result.new_query.use_llm_model_uuid == 'text-only-model' + assert [(part.type, part.text) for part in result.new_query.user_message.content] == [ + ('text', 'describe this'), + ('text', '[Image]'), + ] + assert result.new_query.variables['user_message_text'] == 'describe this[Image]' class TestPreProcessorModelSelection: @@ -455,11 +485,18 @@ class TestPreProcessorVariables: result = await stage.process(query, 'PreProcessor') variables = result.new_query.variables - assert 'launcher_type' in variables - assert 'launcher_id' in variables - assert 'sender_id' in variables - assert variables['sender_id'] == 67890 - assert 'user_message_text' in variables + expected = { + 'launcher_type': 'person', + 'launcher_id': 12345, + 'sender_id': 67890, + 'session_id': 'person_12345', + 'conversation_id': 'conv-123', + 'msg_create_time': 1609459200, + 'group_name': '', + 'sender_name': 'TestUser', + 'user_message_text': 'hello', + } + assert {key: variables[key] for key in expected} == expected @pytest.mark.asyncio async def test_group_variables_include_group_name(self): @@ -493,8 +530,8 @@ class TestPreProcessorVariables: result = await stage.process(query, 'PreProcessor') variables = result.new_query.variables - assert 'group_name' in variables - assert 'sender_name' in variables + assert variables['group_name'] == 'TestGroup' + assert variables['sender_name'] == 'TestUser' class TestPreProcessorToolSelection: diff --git a/tests/unit_tests/pipeline/test_ratelimit.py b/tests/unit_tests/pipeline/test_ratelimit.py index e23636ab7..b4485c765 100644 --- a/tests/unit_tests/pipeline/test_ratelimit.py +++ b/tests/unit_tests/pipeline/test_ratelimit.py @@ -272,11 +272,14 @@ class TestFixedWindowAlgo: algo = fixedwin.FixedWindowAlgo(mock_app_for_algo) await algo.initialize() - # release_access is empty in current implementation + await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345') + containers = dict(algo.containers) + records = {key: dict(container.records) for key, container in containers.items()} + 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 + assert algo.containers == containers + assert {key: dict(container.records) for key, container in algo.containers.items()} == records # Original mock-based tests for RateLimit stage integration diff --git a/tests/unit_tests/pipeline/test_resprule.py b/tests/unit_tests/pipeline/test_resprule.py index 63dfbfd0d..4032ad6ff 100644 --- a/tests/unit_tests/pipeline/test_resprule.py +++ b/tests/unit_tests/pipeline/test_resprule.py @@ -138,3 +138,147 @@ async def test_atbot_rule_no_match(mock_app, sample_query): result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query) assert result.matching is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('routed', [False, True]) +@pytest.mark.parametrize('kind', ['plain', 'mention', 'prefix']) +async def test_routed_group_messages_still_obey_response_rules(mock_app, sample_query, routed, kind): + resprule, entities, _, _ = get_modules() + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + sample_query.variables = {'_routed_by_rule': routed} + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} + } + parts = [platform_message.Plain(text='!bot hello' if kind == 'prefix' else 'hello')] + if kind == 'mention': + parts.insert(0, platform_message.At(target='999')) + sample_query.message_chain = platform_message.MessageChain(parts) + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + expected = entities.ResultType.INTERRUPT if kind == 'plain' else entities.ResultType.CONTINUE + assert result.result_type == expected + if kind != 'plain': + assert str(sample_query.message_chain) == 'hello' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('routed', [False, True]) +@pytest.mark.parametrize( + 'overrides,text,mention,draw,matches,expected_text', + [ + pytest.param({'regexp': ['^hello$']}, 'hello', False, 0.9, True, 'hello', id='regexp-match'), + pytest.param({'regexp': ['^hello$']}, 'bye', False, 0.9, False, 'bye', id='regexp-miss'), + pytest.param({'regexp': ['^bye$', '^hello$']}, 'hello', False, 0.9, True, 'hello', id='second-regexp'), + pytest.param({'regexp': ['[']}, 'hello', False, 0.9, False, 'hello', id='invalid-regexp'), + pytest.param({'random': 0}, 'hello', False, 0.0, False, 'hello', id='random-disabled'), + pytest.param({'random': 1}, 'hello', False, 0.999, True, 'hello', id='random-always'), + pytest.param({'random': 0.25}, 'hello', False, 0.249, True, 'hello', id='random-hit'), + pytest.param({'random': 0.25}, 'hello', False, 0.25, False, 'hello', id='random-boundary'), + pytest.param({}, 'hello', True, 0.0, False, 'hello', id='mention-disabled'), + pytest.param({}, '!bot hello', False, 0.0, False, '!bot hello', id='prefix-disabled'), + pytest.param({'prefix': ['!other ', '!bot ']}, '!bot hello', False, 0.9, True, 'hello', id='second-prefix'), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^hello$'], 'random': 0.25}, + 'hello', + False, + 0.9, + True, + 'hello', + id='combined-regexp-match', + ), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25}, + 'hello', + False, + 0.1, + True, + 'hello', + id='combined-random-fallback', + ), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25}, + 'hello', + False, + 0.9, + False, + 'hello', + id='combined-all-miss', + ), + ], +) +async def test_group_response_rule_configuration( + mock_app, sample_query, monkeypatch, routed, overrides, text, mention, draw, matches, expected_text +): + resprule, entities, _, _ = get_modules() + random_rule = import_module('langbot.pkg.pipeline.resprule.rules.random') + monkeypatch.setattr(random_rule.random, 'random', lambda: draw) + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + sample_query.variables = {'_routed_by_rule': routed} + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': False, 'prefix': [], 'regexp': [], 'random': 0, **overrides}} + } + parts = [platform_message.Plain(text=text)] + if mention: + parts.insert(0, platform_message.At(target='999')) + sample_query.message_chain = platform_message.MessageChain(parts) + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + assert result.result_type == (entities.ResultType.CONTINUE if matches else entities.ResultType.INTERRUPT) + assert str(result.new_query.message_chain) == expected_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize('callback_key', ['_interaction_submission', '_dify_form_action']) +async def test_interaction_callbacks_do_not_require_another_mention(mock_app, sample_query, callback_key): + resprule, entities, _, _ = get_modules() + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + submission = {'action_id': 'approve'} + sample_query.variables = {'_routed_by_rule': True, callback_key: submission} + sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')]) + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} + } + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + assert result.result_type == entities.ResultType.CONTINUE + assert str(result.new_query.message_chain) == '[Form Action: Approve]' + assert result.new_query.variables[callback_key] == submission + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'variables', + [ + {'_routed_by_rule': True, '_dify_form_action': None}, + {'_routed_by_rule': True, '_dify_form_action': {}}, + {'_routed_by_rule': False, '_dify_form_action': {'action_id': 'approve'}}, + ], +) +async def test_incomplete_form_callback_does_not_bypass_response_rules(mock_app, sample_query, variables): + resprule, entities, _, _ = get_modules() + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + sample_query.variables = variables + sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')]) + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} + } + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + assert result.result_type == entities.ResultType.INTERRUPT diff --git a/tests/unit_tests/pipeline/test_stream_results.py b/tests/unit_tests/pipeline/test_stream_results.py new file mode 100644 index 000000000..7ddd7af3c --- /dev/null +++ b/tests/unit_tests/pipeline/test_stream_results.py @@ -0,0 +1,112 @@ +import pytest + +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk +from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain +from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results + + +async def source(*items): + for item in items: + yield item + + +@pytest.mark.asyncio +async def test_terminal_snapshot_is_delivered_once_with_attachments(): + text = 'reasoninganswer' + completed = Message(role='assistant', content=text) + completed.attachments = MessageChain([File(url='https://example.com/report.txt', name='report.txt')]) + results = [ + item + async for item in coalesce_stream_results( + source( + MessageChunk(role='assistant', content='answer', all_content=text, is_final=True), + completed, + ) + ) + ] + assert results == [completed] + assert results[0].attachments is completed.attachments + + +@pytest.mark.asyncio +@pytest.mark.parametrize('with_snapshot', [False, True]) +async def test_progressive_chunks_are_not_buffered(with_snapshot): + first = MessageChunk(role='assistant', content='hel', all_content='hel') + final = MessageChunk(role='assistant', content='lo', all_content='hello', is_final=True) + completed = Message(role='assistant', content='hello') + stream = coalesce_stream_results(source(first, final, *([completed] if with_snapshot else []))) + assert await anext(stream) == first + assert [item async for item in stream] == [completed if with_snapshot else final] + + +@pytest.mark.asyncio +async def test_completed_only_and_distinct_messages_are_preserved(): + final = MessageChunk(role='assistant', content='first', is_final=True) + other = Message(role='assistant', content='second') + results = [item async for item in coalesce_stream_results(source(final, other))] + assert [item.content for item in results] == ['first', 'second'] + assert [item async for item in coalesce_stream_results(source(other))] == [other] + + +@pytest.mark.asyncio +async def test_blank_terminal_delta_uses_completed_snapshot(): + completed = Message(role='assistant', content='answer') + results = [ + item + async for item in coalesce_stream_results( + source( + MessageChunk(role='assistant', is_final=True), + completed, + ) + ) + ] + assert results == [completed] + + +@pytest.mark.asyncio +async def test_failures_propagate_without_successful_final(): + async def failing(): + yield MessageChunk(role='assistant', content='partial') + raise RuntimeError('provider failed') + + stream = coalesce_stream_results(failing()) + assert (await anext(stream)).content == 'partial' + with pytest.raises(RuntimeError, match='provider failed'): + await anext(stream) + + +@pytest.mark.asyncio +async def test_cumulative_stream_ends_with_last_snapshot_even_when_final_is_blank(): + results = [ + item + async for item in coalesce_stream_results( + source( + MessageChunk(role='assistant', content='hel'), + MessageChunk(role='assistant', content='hello'), + MessageChunk(role='assistant', is_final=True), + ) + ) + ] + assert [item.all_content for item in results] == ['hel', 'hello', 'hello'] + assert results[-1].is_final + + +@pytest.mark.asyncio +async def test_multi_round_completion_keeps_one_transcript_and_attachments(): + first = 'planI will create a file.' + last = 'file createdHere is your file.' + completed = Message(role='assistant', content=last) + completed.attachments = MessageChain([File(name='report.txt', base64='aGVsbG8=')]) + results = [ + item + async for item in coalesce_stream_results( + source( + MessageChunk(role='assistant', content=first, is_final=False), + MessageChunk(role='assistant', content=first + last, is_final=True), + completed, + ) + ) + ] + assert len(results) == 2 + assert results[-1].content == first + last + assert results[-1].attachments is completed.attachments diff --git a/tests/unit_tests/pipeline/test_wrapper.py b/tests/unit_tests/pipeline/test_wrapper.py index 1dd9efbb8..38e014321 100644 --- a/tests/unit_tests/pipeline/test_wrapper.py +++ b/tests/unit_tests/pipeline/test_wrapper.py @@ -22,6 +22,7 @@ from tests.factories import ( import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.provider.session as provider_session +from langbot_plugin.api.entities.builtin.provider.message import Message def get_wrapper_module(): @@ -109,7 +110,7 @@ class TestResponseWrapperMessageChain: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - assert len(results[0].new_query.resp_message_chain) == 1 + assert [str(chain) for chain in results[0].new_query.resp_message_chain] == ['response'] @pytest.mark.asyncio async def test_message_chain_direct_append_consumes_pending_plugin_source(self): @@ -171,12 +172,7 @@ class TestResponseWrapperCommand: query.pipeline_config = pipeline_config query.resp_message_chain = [] - # Create a command response message - 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')]) - ) + command_resp = Message(role='command', content='Help info') query.resp_messages = [command_resp] results = [] @@ -185,8 +181,7 @@ class TestResponseWrapperCommand: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - # Check that prefix was added (via get_content_platform_message_chain) - command_resp.get_content_platform_message_chain.assert_called_once() + assert [str(chain) for chain in query.resp_message_chain] == ['[bot] Help info'] class TestResponseWrapperPlugin: @@ -223,6 +218,7 @@ class TestResponseWrapperPlugin: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE + assert [str(chain) for chain in query.resp_message_chain] == ['Plugin response'] class TestResponseWrapperAssistant: @@ -316,8 +312,11 @@ class TestResponseWrapperAssistant: assert len(results) == 1 assert results[0].result_type == entities.ResultType.CONTINUE - # Event should have been emitted - app.plugin_connector.emit_event.assert_called() + assert [str(chain) for chain in query.resp_message_chain] == ['Hello back!'] + app.plugin_connector.emit_event.assert_awaited_once() + event = app.plugin_connector.emit_event.await_args.args[0] + assert event.response_text == 'Hello back!' + assert event.funcs_called == [] @pytest.mark.asyncio async def test_assistant_empty_content(self): @@ -386,24 +385,36 @@ class TestResponseWrapperAssistant: mock_tool_call = Mock() mock_tool_call.function = Mock() mock_tool_call.function.name = 'test_function' + other_tool_call = Mock() + other_tool_call.function.name = 'other_function' assistant_resp = Mock() assistant_resp.role = 'assistant' assistant_resp.content = 'Processing...' - assistant_resp.tool_calls = [mock_tool_call] + assistant_resp.tool_calls = [mock_tool_call, other_tool_call] assistant_resp.get_content_platform_message_chain = Mock( return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')]) ) query.resp_messages = [assistant_resp] results = [] + delivered = [] async for result in stage.process(query, 'ResponseWrapper'): results.append(result) + delivered.append(str(query.resp_message_chain[-1])) assert len(results) == 2 for result in results: assert result.result_type == entities.ResultType.CONTINUE assert app.plugin_connector.emit_event.await_count == 2 + expected = ['Processing...', 'Calling test_function, other_function...'] + assert delivered == expected + events = [sent.args[0] for sent in app.plugin_connector.emit_event.await_args_list] + assert [event.response_text for event in events] == expected + assert [event.funcs_called for event in events] == [ + ['test_function', 'other_function'], + ['test_function', 'other_function'], + ] class TestResponseWrapperInterrupt: diff --git a/tests/unit_tests/platform/onebot_mock.py b/tests/unit_tests/platform/onebot_mock.py new file mode 100644 index 000000000..6295bae75 --- /dev/null +++ b/tests/unit_tests/platform/onebot_mock.py @@ -0,0 +1,107 @@ +"""Independent OneBot v11 wire peer; no LangBot transport/converter imports. + +Contract: https://github.com/botuniverse/onebot-11 (api/public.md, +communication/{ws,ws-reverse,http}.md and event/*.md). +Only test data is served; no real QQ account or vendor service is contacted. +""" + +import asyncio +import json + + +class OneBotMock: + def __init__(self): + self.ready = asyncio.Event() + self.requests = [] + self.send = None + self.response = None + self.user = {'user_id': 10001, 'nickname': 'Alice', 'sex': 'unknown', 'age': 20} + self.group = {'group_id': 20001, 'group_name': 'Mock group', 'member_count': 2, 'max_member_count': 200} + self.member = self.user | {'group_id': 20001, 'card': 'Card', 'role': 'admin', 'title': 'Title', 'join_time': 1} + + async def run(self, send, receive): + self.send = send + self.ready.set() + while True: + request = json.loads(await receive()) + self.requests.append(request) + if self.response is not None: + response = dict(self.response) + else: + response = self.handle(request) + await send(json.dumps(response | {'echo': request.get('echo')})) + + def handle(self, request): + action, params = request['action'], request.get('params', {}) + for key in ('user_id', 'group_id', 'message_id', 'duration'): + if key in params: + assert type(params[key]) is int, (action, key, params[key]) + data = None + if action in ('send_msg', 'send_private_msg', 'send_group_msg'): + assert isinstance(params['message'], (list, str)) + data = {'message_id': 123} + elif action == 'get_msg': + data = self.message('private', 'friend') + elif action == 'get_login_info': + data = {'user_id': 42, 'nickname': 'Mock bot'} + elif action == 'get_stranger_info': + data = self.user + elif action == 'get_friend_list': + data = [self.user | {'remark': 'Friend'}] + elif action == 'get_group_info': + data = self.group + elif action == 'get_group_list': + data = [self.group] + elif action == 'get_group_member_info': + data = self.member + elif action == 'get_group_member_list': + data = [self.member] + elif action == 'get_status': + data = {'online': True, 'good': True} + elif action == 'get_version_info': + data = {'app_name': 'onebot-test-peer', 'app_version': '1', 'protocol_version': 'v11'} + elif action in ('can_send_image', 'can_send_record'): + data = {'yes': True} + elif action in ('get_image', 'get_record'): + data = {'file': '/mock/media'} + elif action == 'get_forward_msg': + data = {'message': []} + elif action == 'get_group_honor_info': + data = {'group_id': 20001} + elif action not in { + 'delete_msg', + 'set_group_name', + 'set_group_ban', + 'set_group_kick', + 'set_group_leave', + 'set_friend_add_request', + 'set_group_add_request', + 'set_group_card', + 'set_group_special_title', + 'set_group_admin', + 'set_group_whole_ban', + }: + return {'status': 'failed', 'retcode': 1404, 'data': None} + return {'status': 'ok', 'retcode': 0, 'data': data} + + async def emit(self, payload): + await self.ready.wait() + await self.send(json.dumps({'time': 1700000000, 'self_id': 42} | payload)) + + def message(self, kind, subtype, message=None): + result = { + 'post_type': 'message', + 'message_type': kind, + 'sub_type': subtype, + 'time': 1700000000, + 'self_id': 42, + 'message_id': 9, + 'user_id': 10001, + 'message': message if message is not None else [{'type': 'text', 'data': {'text': 'hello'}}], + 'raw_message': 'hello', + 'font': 0, + 'sender': self.user, + } + if kind == 'group': + result.update(group_id=20001, anonymous=None, sender=self.member) + return result diff --git a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py index 584d39d91..58473bf00 100644 --- a/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py +++ b/tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py @@ -34,7 +34,7 @@ class DummyLogger(AbstractEventLogger): def make_adapter() -> AiocqhttpAdapter: - return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': ''}, DummyLogger()) + return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': 'test-secret'}, DummyLogger()) def manifest() -> dict: @@ -48,13 +48,40 @@ def manifest() -> dict: / 'aiocqhttp' / 'manifest.yaml' ) - return yaml.safe_load(manifest_path.read_text()) + return yaml.safe_load(manifest_path.read_text(encoding='utf-8')) def onebot_event(payload: dict) -> aiocqhttp.Event: return aiocqhttp.Event.from_payload(payload) +@pytest.mark.parametrize('meta_type', ['heartbeat', 'lifecycle', 'future_transport_metadata']) +async def test_protocol_metadata_never_reaches_business_listeners(meta_type): + adapter = make_adapter() + listener = AsyncMock() + adapter.register_listener(platform_events.EBAEvent, listener) + payload = {'post_type': 'meta_event', 'meta_event_type': meta_type, 'self_id': 42, 'time': 1700000000} + assert await AiocqhttpEventConverter.target2yiri(onebot_event(payload)) is None + await adapter._receive_event(payload) + listener.assert_not_awaited() + assert adapter.bot_account_id == '42' + + # Genuine platform-specific user interactions still reach business routing. + await adapter._receive_event( + { + 'post_type': 'notice', + 'notice_type': 'notify', + 'sub_type': 'poke', + 'self_id': 42, + 'time': 1700000001, + 'user_id': 10001, + 'target_id': 42, + } + ) + listener.assert_awaited_once() + assert listener.await_args.args[0].type == 'platform.specific' + + def test_aiocqhttp_supported_events_match_manifest(): assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] @@ -255,7 +282,7 @@ async def test_aiocqhttp_event_converter_maps_private_and_group_messages(): assert isinstance(private_event, platform_events.MessageReceivedEvent) assert private_event.type == 'message.received' - assert private_event.adapter_name == 'aiocqhttp-omni' + assert private_event.adapter_name == 'onebot-v11' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 10001 assert private_event.sender.nickname == 'Alice' diff --git a/tests/unit_tests/platform/test_botmgr_execution_trace.py b/tests/unit_tests/platform/test_botmgr_execution_trace.py index 75605aa04..f3a523b0d 100644 --- a/tests/unit_tests/platform/test_botmgr_execution_trace.py +++ b/tests/unit_tests/platform/test_botmgr_execution_trace.py @@ -1,9 +1,10 @@ -"""RuntimeBot ingress tracing: one inbound event owns one execution trace.""" +"""RuntimeBot ingress tracing: one inbound event owns one v2 execution chain.""" from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock, Mock +import asyncio import pytest @@ -28,9 +29,6 @@ class FakeTelemetryManager: self.sent.append(payload) return True - def start_send_task(self, payload: dict) -> None: - self.sent.append(payload) - def make_bot(event_bindings: list[dict], config=None, agent=None): from langbot.pkg.platform.botmgr import RuntimeBot @@ -41,6 +39,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None): bot = object.__new__(RuntimeBot) bot.bot_entity = SimpleNamespace( uuid='bot-1', + adapter='aiocqhttp', workspace_uuid=TEST_CONTEXT.workspace_uuid, event_bindings=event_bindings, plugin_processors=[], @@ -51,6 +50,7 @@ def make_bot(event_bindings: list[dict], config=None, agent=None): bot.logger = SimpleNamespace(info=AsyncMock(), warning=AsyncMock(), error=AsyncMock(), debug=AsyncMock()) bot.adapter = FakeAdapter() bot.ap = SimpleNamespace( + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()), telemetry=SimpleNamespace(execution=counters), agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=agent)), pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(return_value=None)), @@ -76,7 +76,7 @@ class FakeAdapter: async def flushed_records(counters, manager) -> list[dict]: - """Flush buffered telemetry and return everything the pipeline emitted. + """Flush buffered telemetry and return everything the event emitted. Records are batched and uploaded by ``ExecutionCounters.flush()``; there is no per-trace direct send any more. @@ -87,34 +87,26 @@ async def flushed_records(counters, manager) -> list[dict]: return [record for payload in manager.sent for record in payload.get('records', [])] +def execution_nodes(records: list[dict]) -> list[dict]: + return [record for record in records if record.get('event_type') == 'execution_node'] + + +def execution_chains(records: list[dict]) -> list[dict]: + return [record for record in records if record.get('event_type') == 'execution_chain'] + + @pytest.mark.asyncio -async def test_route_miss_emits_one_closed_trace(): +async def test_route_miss_emits_nothing(): bot, manager, counters = make_bot([]) await bot._handle_platform_event(message_received_event(), FakeAdapter()) - records = await flushed_records(counters, manager) - assert len(manager.sent) == 1 - trace_records = [item for item in records if item.get('event_type') == 'feature_execution'] - assert len(trace_records) == 1 - payload = trace_records[0] - assert payload['event_type'] == 'feature_execution' - # The execution identity is deterministic: one inbound event, one chain. - assert payload['query_id'] == 'platform:bot-1:message-1' - assert payload['instance_id'] == 'instance-test' - assert payload['workspace_uuid'] == 'workspace-test' - features = payload['features'] - assert features['schema'] == 1 - assert [(row['family'], row['operation'], row['outcome']) for row in features['observations']] == [ - ('event_route', 'message.received', 'skipped'), - ('platform_event', 'message.received', 'success'), - ] - assert [row['seq'] for row in features['observations']] == [0, 1] - assert all(row['trace_id'] == payload['query_id'] for row in features['observations']) - assert all(row['adapter'] == 'FakeAdapter' for row in features['observations']) - assert features['trace']['closed_by'] == 'event_done' - # The trace is closed and deregistered: nothing dangles after the event. - assert counters.traces == {} + await counters.flush() + await counters.shutdown() + assert manager.sent == [] + assert counters.records == [] + assert counters.dropped == 0 + assert counters.registry == {} @pytest.mark.asyncio @@ -133,17 +125,17 @@ async def test_unavailable_route_target_carries_route_identity(): await bot._handle_platform_event(message_received_event(), FakeAdapter()) - stages = (await flushed_records(counters, manager))[0]['features']['observations'] - assert [(stage['family'], stage['outcome'], stage['mode']) for stage in stages] == [ + nodes = execution_nodes(await flushed_records(counters, manager)) + assert [(node['family'], node['outcome'], node['mode']) for node in nodes] == [ ('event_route', 'failed', 'agent'), ('platform_event', 'success', 'none'), ] - assert stages[0]['route_ref'] == 'agent:agent-1' - assert stages[1]['route_ref'] == '' + assert nodes[0]['route_ref'] == 'agent:agent-1' + assert nodes[1]['route_ref'] == '' @pytest.mark.asyncio -async def test_discarded_route_is_visible_without_user_values(): +async def test_discarded_route_is_only_logged_locally(): bot, manager, counters = make_bot( [ { @@ -157,10 +149,10 @@ async def test_discarded_route_is_visible_without_user_values(): await bot._handle_platform_event(SimpleNamespace(type='platform.member.joined'), FakeAdapter()) - stages = (await flushed_records(counters, manager))[0]['features']['observations'] - assert [stage['outcome'] for stage in stages] == ['skipped', 'success'] - assert stages[0]['operation'] == 'platform.member.joined' - assert stages[0]['mode'] == 'none' + await counters.flush() + assert manager.sent == [] + assert counters.records == [] + assert bot.logger.info.await_args.kwargs['metadata']['status'] == 'discarded' @pytest.mark.asyncio @@ -171,7 +163,7 @@ async def test_telemetry_opt_out_emits_nothing(): assert manager.sent == [] assert counters.records == [] - assert counters.traces == {} + assert counters.registry == {} @pytest.mark.asyncio @@ -185,21 +177,443 @@ async def test_route_trace_scope_is_restored_after_dispatch(): assert trace_mod.current() is None buffered = list(counters.records) - # A later record outside the ingress must not join the closed trace, and an + # A later record outside the ingress must not join the closed chain, and an # observation without an owning chain is not buffered at all. from langbot.pkg.telemetry.execution import record record(bot.ap, TEST_CONTEXT, family='platform_api', operation='send_message', outcome='success') assert counters.records == buffered - assert counters.traces == {} + assert counters.registry == {} @pytest.mark.asyncio -async def test_adapter_call_without_ingress_still_counts(): +async def test_mock_adapter_route_miss_is_not_reported(): """Mock adapter objects used by other tests must not break the ingress path.""" bot, manager, counters = make_bot([]) adapter = Mock() await bot._handle_platform_event(message_received_event(), adapter) - assert await flushed_records(counters, manager) + await counters.flush() + assert manager.sent == [] + + +def business_route(**overrides): + return { + 'id': 'route', + 'enabled': True, + 'event_pattern': '*', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + **overrides, + } + + +async def assert_no_upload(counters, manager): + assert counters.records == [] + assert counters.task is None + await counters.flush() + await counters.shutdown() + assert manager.sent == [] + assert counters.health() == { + 'records_buffered': 0, + 'records_sent': 0, + 'records_dropped': 0, + 'executions_in_flight': 0, + } + + +@pytest.mark.parametrize('edition', ['community', 'cloud']) +@pytest.mark.parametrize('mode', ['all', 'sampled', 'failures', 'off']) +@pytest.mark.parametrize( + 'routes', + [ + [], + [business_route(enabled=False)], + [business_route(event_pattern='group.member_joined')], + [business_route(filters=[{'field': 'chat_id', 'operator': 'eq', 'value': 'another-chat'}])], + [business_route(target_type='discard', priority=10), business_route(priority=1)], + ], +) +async def test_unrouted_events_never_upload_in_any_mode(monkeypatch, edition, mode, routes): + from langbot.pkg.utils import constants + + monkeypatch.setattr(constants, 'edition', edition) + bot, manager, counters = make_bot(routes, config={'url': 'https://space.example.test', 'execution_trace': mode}) + bot._dispatch_eba_message_to_pipeline = AsyncMock() + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + await assert_no_upload(counters, manager) + # Normal local route diagnostics still exist, including explicit discard. + assert bot.logger.info.await_args.kwargs['metadata']['status'] in {'not_matched', 'discarded'} + + +@pytest.mark.parametrize('mode', ['all', 'sampled', 'failures']) +async def test_matched_target_failure_is_retained_even_after_root_success(mode): + bot, manager, counters = make_bot( + [business_route()], + config={ + 'url': 'https://space.example.test', + 'execution_trace': mode, + 'execution_trace_sample': 100000, + }, + ) + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + records = await flushed_records(counters, manager) + (chain,) = execution_chains(records) + assert chain['outcome'] == 'failed' + assert chain['error'] == 'processor_not_found' + if mode == 'failures': + assert execution_nodes(records) == [] + await counters.shutdown() + + +def install_processor(bot, patterns=None): + bot.bot_entity.plugin_processors = [{'processor_uuid': 'processor', 'enabled': True}] + bot.ap.agent_service.get_agent = AsyncMock( + return_value={ + 'kind': 'event_processor', + 'component_ref': 'plugin:test/runner/default', + } + ) + bot.ap.runner_registry = SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + usages=['event'], + supported_event_patterns=['*'] if patterns is None else patterns, + ) + ) + ) + bot._agent_product_to_binding = Mock(return_value=SimpleNamespace()) + bot._eba_event_to_agent_envelope = Mock(return_value=SimpleNamespace()) + + async def run(*args, **kwargs): + from langbot.pkg.telemetry.execution import record + + record(bot.ap, TEST_CONTEXT, family='runner', operation='execute', outcome='success') + if False: + yield None + + bot.ap.agent_run_orchestrator = SimpleNamespace(run=run) + + +@pytest.mark.parametrize('primary', ['miss', 'discard']) +@pytest.mark.parametrize('primary_first', [True, False]) +async def test_matching_subscription_admits_chain_regardless_of_primary_order(primary, primary_first): + from langbot.pkg.telemetry.execution import record + + routes = [] if primary == 'miss' else [business_route(target_type='discard')] + bot, manager, counters = make_bot(routes) + install_processor(bot) + bot.bot_entity.plugin_processors *= 2 # Duplicate subscriptions still execute once. + original = bot._dispatch_eba_event_to_processor + first_done = asyncio.Event() + run_calls = [] + + async def ordered_dispatch(event, adapter, event_binding=None, agent=None, execution_id=None): + is_primary = event_binding is None + if is_primary != primary_first: + await first_done.wait() + result = await original(event, adapter, event_binding, agent, execution_id) + if not is_primary: + run_calls.append(result) + if is_primary == primary_first: + first_done.set() + return result + + async def discard_side_effect(*args, **kwargs): + # A discarded message can still trigger a webhook/adapter side effect. + # A concurrent admitted subscriber must not expose this branch's node. + record(bot.ap, TEST_CONTEXT, family='platform_api', operation='discard_side_effect', outcome='success') + await counters.flush() + + bot._dispatch_eba_event_to_processor = ordered_dispatch + bot._dispatch_eba_message_to_pipeline = discard_side_effect + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + records = await flushed_records(counters, manager) + (chain,) = execution_chains(records) + nodes = execution_nodes(records) + assert len(run_calls) == 1 + assert chain['stage_count'] == 3 + assert {node['family'] for node in nodes} == {'runner', 'event_route', 'platform_event'} + assert all(node['outcome'] == 'success' for node in nodes) + (root,) = [node for node in nodes if node['root']] + assert root['family'] == 'platform_event' + assert all(node['parent_node_id'] == root['node_id'] for node in nodes if not node['root']) + assert all(node['event_id'] == chain['event_id'] for node in nodes) + await counters.shutdown() + + +@pytest.mark.parametrize('kind', ['disabled', 'pattern_miss', 'no_patterns', 'missing', 'declaration_error']) +async def test_unmatched_subscription_cannot_admit_event(kind): + bot, manager, counters = make_bot([]) + install_processor(bot) + if kind == 'disabled': + bot.bot_entity.plugin_processors[0]['enabled'] = False + elif kind in {'pattern_miss', 'no_patterns'}: + install_processor(bot, [] if kind == 'no_patterns' else ['group.member_joined']) + elif kind == 'missing': + bot.ap.agent_service.get_agent.return_value = None + else: + bot.ap.runner_registry.get.side_effect = RuntimeError('unavailable') + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + await assert_no_upload(counters, manager) + + +@pytest.mark.parametrize('failure', [RuntimeError('runner failed'), TimeoutError('timeout'), asyncio.CancelledError()]) +async def test_matched_subscription_keeps_failed_timeout_and_cancelled_execution(failure): + bot, manager, counters = make_bot([], config={'url': 'https://space.example.test', 'execution_trace': 'failures'}) + install_processor(bot) + + async def run(*args, **kwargs): + raise failure + yield + + bot.ap.agent_run_orchestrator.run = run + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + (chain,) = execution_chains(await flushed_records(counters, manager)) + assert chain['outcome'] == 'failed' + assert chain['error'] + await counters.shutdown() + + +@pytest.mark.parametrize('processor', ['agent', 'pipeline', 'invalid', 'expired', 'duplicate']) +async def test_interaction_admission_requires_valid_callback(processor): + from langbot_plugin.api.entities.builtin.platform import events + + bot, manager, counters = make_bot([]) + callbacks = SimpleNamespace( + consume_callback=AsyncMock(return_value={'processor_type': processor, 'processor_id': 'target'}), + acknowledge_submission=AsyncMock(), + ) + if processor in {'expired', 'duplicate'}: + callbacks.consume_callback.side_effect = ValueError(processor) + bot.ap.agent_run_orchestrator = SimpleNamespace(interaction_manager=callbacks) + bot._resume_agent_interaction = AsyncMock(side_effect=ValueError('target unavailable')) + event = events.PlatformSpecificEvent(platform='test', action='interaction.submitted', data={}) + await bot._handle_platform_event(event, FakeAdapter()) + if processor in {'agent', 'pipeline'}: + (chain,) = execution_chains(await flushed_records(counters, manager)) + assert chain['outcome'] == 'failed' + callbacks.acknowledge_submission.assert_awaited_once() + await counters.shutdown() + else: + callbacks.acknowledge_submission.assert_not_awaited() + await assert_no_upload(counters, manager) + + +async def test_rejected_event_does_not_contaminate_next_matched_event_or_monitoring(): + from langbot.pkg.telemetry import trace + + bot, manager, counters = make_bot([]) + bot._persist_monitoring_ingress = AsyncMock() + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + assert bot._persist_monitoring_ingress.await_count == 2 + assert counters.records == [] + assert trace.current() is None + assert trace.node_stack() == () + assert counters.registry == {} + bot.bot_entity.event_bindings = [business_route()] + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + assert len(execution_chains(await flushed_records(counters, manager))) == 1 + await counters.shutdown() + + +async def test_pending_gate_blocks_nested_and_rpc_records_and_failure_fallback(): + import contextvars + from langbot.pkg.telemetry import execution, trace + + bot, manager, counters = make_bot([]) + with pytest.raises(RuntimeError): + with execution.ingress(bot.ap, context=TEST_CONTEXT, execution_id='pending', require_route=True, debug=True): + with execution.ingress(bot.ap, context=TEST_CONTEXT, debug=True): + execution.bind_trace(bot.ap, 'run-alias') + execution.record(bot.ap, TEST_CONTEXT, family='tool', operation='nested', outcome='failed') + + async def rpc(): + execution.record( + bot.ap, + TEST_CONTEXT, + execution_id='run-alias', + family='platform_api', + operation='rpc', + outcome='failed', + ) + await counters.flush() + + await contextvars.Context().run(asyncio.create_task, rpc()) + assert counters.records == [] + raise RuntimeError('failed before a route matched') + assert trace.current() is None + await assert_no_upload(counters, manager) + + +@pytest.mark.parametrize('origin', ['api', 'webui']) +async def test_independent_debug_entry_still_reports(origin): + from langbot.pkg.telemetry import execution + + bot, manager, counters = make_bot([], config={'url': 'https://space.example.test', 'execution_trace': 'failures'}) + with execution.ingress( + bot.ap, + context=TEST_CONTEXT, + execution_id='debug', + debug=True, + origin=origin, + synthetic_event='message.received', + ): + execution.record(bot.ap, TEST_CONTEXT, family='runner', operation='execute', outcome='success') + (chain,) = execution_chains(await flushed_records(counters, manager)) + assert chain['debug'] is True + assert chain['origin'] == origin + await counters.shutdown() + + +@pytest.mark.parametrize('edition', ['community', 'cloud']) +async def test_admission_does_not_override_telemetry_opt_out(monkeypatch, edition): + from langbot.pkg.utils import constants + + monkeypatch.setattr(constants, 'edition', edition) + bot, manager, counters = make_bot( + [business_route()], + config={ + 'url': 'https://space.example.test', + 'disable_telemetry': True, + }, + ) + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + await assert_no_upload(counters, manager) + + +async def test_unrelated_broken_subscription_does_not_poison_matched_delivery(): + bot, manager, counters = make_bot([business_route(target_type='pipeline')]) + bot._dispatch_eba_message_to_pipeline = AsyncMock() + bot.bot_entity.plugin_processors = [{'processor_uuid': 'missing'}] + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + records = await flushed_records(counters, manager) + (chain,) = execution_chains(records) + assert chain['outcome'] == 'success' + assert chain['error'] == '' + assert all(node['outcome'] == 'success' for node in execution_nodes(records)) + bot.logger.error.assert_awaited_once() + await counters.shutdown() + + +async def test_pending_event_cancelled_during_dispatch_never_uploads(): + bot, manager, counters = make_bot([]) + started = asyncio.Event() + + async def waiting_dispatch(*args, **kwargs): + started.set() + await asyncio.Event().wait() + + bot._dispatch_eba_event_to_processor = waiting_dispatch + task = asyncio.create_task(bot._handle_platform_event(message_received_event(), FakeAdapter())) + try: + await asyncio.wait_for(started.wait(), timeout=5) + await counters.flush() + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await assert_no_upload(counters, manager) + + +async def test_retry_of_matched_batch_cannot_include_intervening_unrouted_event(): + bot, manager, counters = make_bot([business_route()]) + await bot._handle_platform_event(message_received_event(), FakeAdapter()) + expected = list(counters.records) + original_send = manager.send + manager.send = AsyncMock(return_value=False) + assert await counters.flush() is False + assert counters.records == expected + bot.bot_entity.event_bindings = [] + ignored = message_received_event() + ignored.message_id = 'ignored-message' + await bot._handle_platform_event(ignored, FakeAdapter()) + assert counters.records == expected + manager.send = original_send + await counters.shutdown() + sent = [record for batch in manager.sent for record in batch['records']] + assert sent == expected + assert counters.dropped == 0 + + +async def test_valid_interaction_success_keeps_acknowledgement_and_execution(): + from langbot.pkg.telemetry.execution import record + from langbot_plugin.api.entities.builtin.platform import events + + bot, manager, counters = make_bot([]) + + async def acknowledge(*args): + record(bot.ap, TEST_CONTEXT, family='platform_api', operation='ack', outcome='success') + + async def resume(*args): + record(bot.ap, TEST_CONTEXT, family='runner', operation='execute', outcome='success') + + bot.ap.agent_run_orchestrator = SimpleNamespace( + interaction_manager=SimpleNamespace( + consume_callback=AsyncMock(return_value={'processor_type': 'agent'}), + acknowledge_submission=acknowledge, + ) + ) + bot._resume_agent_interaction = resume + event = events.PlatformSpecificEvent(platform='test', action='interaction.submitted', data={}) + await bot._handle_platform_event(event, FakeAdapter()) + records = await flushed_records(counters, manager) + (chain,) = execution_chains(records) + assert chain['outcome'] == 'success' + assert {node['family'] for node in execution_nodes(records)} == {'platform_event', 'platform_api', 'runner'} + await counters.shutdown() + + +@pytest.mark.parametrize('channel_id', ['channel-1', 'channel-2']) +def test_discord_reply_target_uses_channel_and_preserves_guild(channel_id): + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform import entities, events, message + + event = events.MessageReceivedEvent( + message_id='message-1', + message_chain=message.MessageChain([message.Plain(text='hello')]), + sender=entities.User(id='user-1'), + chat_type=entities.ChatType.GROUP, + chat_id=channel_id, + group=entities.UserGroup(id='guild-1'), + adapter_name='discord-omni', + ) + assert RuntimeBot._infer_reply_target(event) == ( + 'group', + channel_id, + {'group_id': 'guild-1', 'chat_id': channel_id}, + ) + + +def test_group_event_without_chat_retains_group_reply_target(): + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform import entities, events + + event = events.MemberJoinedEvent(group=entities.UserGroup(id='group-1'), member=entities.User(id='user-1')) + assert RuntimeBot._infer_reply_target(event) == ('group', 'group-1', {'group_id': 'group-1'}) + + +def test_agent_event_keeps_image_file_and_voice_attachments(): + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform import entities, events, message + + event = events.MessageReceivedEvent( + message_id='media-1', + message_chain=message.MessageChain( + [ + message.Image(url='https://cdn.example.test/image.png'), + message.File(name='input.txt', url='https://cdn.example.test/input.txt'), + message.Voice(base64='dGVzdA=='), + ] + ), + sender=entities.User(id='user-1'), + chat_type=entities.ChatType.PRIVATE, + chat_id='channel-1', + ) + agent_input = RuntimeBot._build_agent_input(event) + assert [attachment.type for attachment in agent_input.attachments] == ['image', 'file', 'voice'] + assert agent_input.attachments[0].url == 'https://cdn.example.test/image.png' + assert agent_input.attachments[1].name == 'input.txt' + assert agent_input.attachments[1].url == 'https://cdn.example.test/input.txt' + assert agent_input.attachments[2].content == 'dGVzdA==' diff --git a/tests/unit_tests/platform/test_botmgr_tenancy.py b/tests/unit_tests/platform/test_botmgr_tenancy.py index ac100bd46..9687ff8bf 100644 --- a/tests/unit_tests/platform/test_botmgr_tenancy.py +++ b/tests/unit_tests/platform/test_botmgr_tenancy.py @@ -38,6 +38,7 @@ def _runtime(application, workspace_uuid: str, bot_uuid: str) -> RuntimeBot: uuid=bot_uuid, workspace_uuid=workspace_uuid, name='Same Name', + adapter='aiocqhttp', enable=True, event_bindings=[], ) @@ -243,7 +244,8 @@ async def test_reload_stops_and_drops_existing_platform_runtimes(): @pytest.mark.asyncio @pytest.mark.parametrize( ('saved_adapter', 'registered_adapter'), - [('probe', 'probe')] + [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)], + [('probe', 'probe'), ('aiocqhttp-eba', 'onebot-v11'), ('aiocqhttp-omni', 'onebot-v11')] + + [(f'{name}-eba', f'{name}-omni') for name in sorted(OMNI_ADAPTER_NAMES)], ) async def test_cloud_startup_reuses_validated_platform_binding(saved_adapter, registered_adapter): class TenantUow: @@ -277,7 +279,7 @@ async def test_cloud_startup_reuses_validated_platform_binding(saved_adapter, re description='', adapter=saved_adapter, adapter_config={}, - enable=False, + enable=True, event_bindings=[], ) workspace_service = SimpleNamespace( @@ -326,6 +328,7 @@ def test_runtime_bot_rejects_workspace_mismatch(): uuid=BOT_A, workspace_uuid=WORKSPACE_A, name='Bot', + adapter='aiocqhttp', enable=True, event_bindings=[], ) @@ -385,6 +388,7 @@ async def test_platform_callback_carries_scope_without_holding_database_session( uuid=BOT_A, workspace_uuid=WORKSPACE_A, name='Bot', + adapter='aiocqhttp', enable=True, event_bindings=[], ) diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py index 450128f3c..91803baac 100644 --- a/tests/unit_tests/platform/test_dingtalk_api.py +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -46,7 +46,7 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures(): async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch): - response = type('Response', (), {'status_code': 200})() + response = type('Response', (), {'status_code': 200, 'json': lambda self: {}})() post = AsyncMock(return_value=response) @asynccontextmanager @@ -78,6 +78,7 @@ async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatc 'cardParamMap': { 'content': 'hello', 'config': '{"autoLayout": true}', + 'autoLayout': 'true', } } assert original_params == {'content': 'hello'} diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index 81ac54c51..74d5a1c04 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import pathlib from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest import yaml @@ -38,7 +38,7 @@ class DummyLogger(AbstractEventLogger): class DummyDingTalkClient(DingTalkClient): def __init__(self, *args, **kwargs): self._message_handlers = {} - self.client = SimpleNamespace(register_callback_handler=AsyncMock()) + self.client = SimpleNamespace(register_callback_handler=Mock(), register_all_event_handler=Mock()) self.markdown_card = kwargs.get('markdown_card', True) self.access_token = '' self.send_message = AsyncMock() @@ -243,7 +243,7 @@ async def test_dingtalk_message_converter_maps_inbound_components(): assert isinstance(chain[0], platform_message.Source) assert isinstance(chain[1], platform_message.At) assert isinstance(chain[2], platform_message.Plain) - assert chain[2].text == ' hello' + assert chain[2].text == '@LangBot hello' assert isinstance(chain[3], platform_message.File) assert isinstance(chain[4], platform_message.Quote) assert str(chain[4].origin) == 'quoted text' @@ -287,8 +287,8 @@ async def test_dingtalk_event_converter_maps_card_feedback(): assert feedback.session_id == 'space-1' assert feedback.message_id == 'bot-msg-1' - assert isinstance(callback, platform_events.PlatformSpecificEvent) - assert callback.action == 'card.callback' + assert isinstance(callback, platform_events.DingTalkCardActionEvent) + assert callback.action == 'open_details' @pytest.mark.asyncio @@ -522,3 +522,40 @@ async def test_dingtalk_adapter_dispatches_native_input_submission(): assert calls[0].data['callback_token'] == 'callback-token' assert calls[0].data['values'] == {'score': 42.5} assert result['message_id'] not in adapter.interaction_callback_contexts + + +def test_dingtalk_subscription_member_and_group_mapping(): + from dingtalk_stream.frames import EventMessage + from langbot.pkg.platform.adapters.dingtalk.subscriptions import subscription_events + + message = EventMessage() + message.headers.event_type = 'chat_add_member' + message.data = {'OpenConversationId': 'group', 'UserId': ['a', 'b']} + converted = list(subscription_events(message)) + assert [event.member.id for event in converted] == ['a', 'b'] + assert all(event.type == 'group.member_joined' for event in converted) + message.headers.event_type = 'chat_update_owner' + message.data = {'OpenConversationId': 'group', 'Owner': 'new-owner'} + event = list(subscription_events(message))[0] + assert event.changed_fields == ['owner_id'] + assert event.group.owner_id == 'new-owner' + message.headers.event_type = 'chat_add_member' + message.data = {'chatId': 'group', 'unionId': ['union-id']} + assert list(subscription_events(message))[0].type == 'platform.specific' + + +@pytest.mark.asyncio +async def test_dingtalk_subscription_retry_does_not_repeat_successful_members(): + from dingtalk_stream.frames import EventMessage + from langbot.pkg.platform.adapters.dingtalk.subscriptions import DingTalkSubscriptionHandler + + message = EventMessage() + message.headers.event_id = 'event-1' + message.headers.event_type = 'chat_add_member' + message.data = {'OpenConversationId': 'group', 'UserId': ['a', 'b']} + adapter = SimpleNamespace(_dispatch_eba_event=AsyncMock(side_effect=[None, RuntimeError('retry'), None])) + handler = DingTalkSubscriptionHandler(adapter) + assert (await handler.process(message))[0] == 500 + assert (await handler.process(message))[0] == 200 + assert (await handler.process(message))[0] == 200 + assert [call.args[0].member.id for call in adapter._dispatch_eba_event.await_args_list] == ['a', 'b', 'b'] diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index a4bdc4b08..0e776bf1a 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -3,7 +3,7 @@ from __future__ import annotations import pathlib import datetime from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest import yaml @@ -344,3 +344,160 @@ async def test_discord_send_and_reply_omit_empty_files_and_return_message_result assert channel.send.await_args.kwargs['content'] == 'reply' assert channel.send.await_args.kwargs['reference'] is source_message assert channel.send.await_args.kwargs['mention_author'] is False + + +def stream_adapter(): + adapter = make_adapter() + sent = SimpleNamespace(content='', edit=AsyncMock()) + sent.edit.return_value = sent + channel = SimpleNamespace(send=AsyncMock(return_value=sent)) + adapter._stream_buffer['stream'] = { + 'channel': channel, + 'sent_message': None, + 'last_content': '', + 'chunk_count': 0, + 'updated_at': 0, + } + return adapter, channel, sent + + +@pytest.mark.asyncio +async def test_discord_stream_delivers_all_text_across_message_limit(): + adapter, channel, sent = stream_adapter() + source = platform_events.MessageReceivedEvent(message_id=1) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'stream'}, + platform_message.MessageChain([platform_message.Plain(text='preview')]), + ) + sent.content = 'preview' + content = '0123456789' * 450 + 'END' + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'stream'}, + platform_message.MessageChain([platform_message.Plain(text=content)]), + is_final=True, + ) + sent.edit.assert_awaited_once_with(content=content[:2000]) + remainder = ''.join(call.args[0] for call in channel.send.await_args_list[1:]) + assert content[:2000] + remainder == content + assert all(len(call.args[0]) <= 2000 for call in channel.send.await_args_list) + assert not adapter._stream_buffer + + +@pytest.mark.asyncio +async def test_discord_stream_keeps_tool_round_and_delivers_terminal_attachment(): + adapter, channel, sent = stream_adapter() + object.__setattr__(adapter, 'reply_message', AsyncMock()) + source = platform_events.MessageReceivedEvent(message_id=1) + attachment = platform_message.File(name='result.txt', base64='T0s=') + message = platform_message.MessageChain([attachment]) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'stream', 'tool_calls': [{'id': 'tool'}]}, + message, + is_final=True, + ) + assert 'stream' in adapter._stream_buffer + adapter.reply_message.assert_not_awaited() + await adapter.reply_message_chunk(source, {'resp_message_id': 'stream'}, message, is_final=True) + adapter.reply_message.assert_awaited_once_with(source, message, False) + channel.send.assert_not_awaited() + assert not adapter._stream_buffer + + +@pytest.mark.asyncio +async def test_discord_stream_final_failure_is_not_reported_as_success(): + adapter, channel, _ = stream_adapter() + channel.send.side_effect = RuntimeError('delivery failed') + with pytest.raises(RuntimeError, match='delivery failed'): + await adapter.reply_message_chunk( + platform_events.MessageReceivedEvent(message_id=1), + {'resp_message_id': 'stream'}, + platform_message.MessageChain([platform_message.Plain(text='reply')]), + is_final=True, + ) + assert not adapter._stream_buffer + + +@pytest.mark.asyncio +async def test_discord_gateway_uses_one_raw_path_for_cached_events(): + adapter = make_adapter() + object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock()) + assert not hasattr(adapter.bot, 'on_reaction_add') + assert not hasattr(adapter.bot, 'on_reaction_remove') + assert not hasattr(adapter.bot, 'on_message_delete') + payload = SimpleNamespace(user_id=123) + await adapter.bot.on_raw_reaction_add(payload) + adapter._dispatch_gateway_tuple.assert_awaited_once_with('raw_reaction_add', payload, None) + adapter._dispatch_gateway_tuple.reset_mock() + cached = fake_message() + await adapter.bot.on_raw_message_delete(SimpleNamespace(cached_message=cached)) + adapter._dispatch_gateway_tuple.assert_awaited_once_with('message_delete', cached, None) + + +@pytest.mark.asyncio +async def test_discord_does_not_route_its_own_stream_edits(): + adapter = make_adapter() + object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock()) + before = fake_message(author=fake_user(bot=True)) + await adapter.bot.on_message_edit(before, before) + adapter._dispatch_gateway_tuple.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_uncached_edit_fetches_message_without_duplicate_or_preview_events(): + adapter = make_adapter() + after = fake_message() + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=after)) + object.__setattr__(adapter, '_get_channel', AsyncMock(return_value=channel)) + object.__setattr__(adapter, '_dispatch_gateway_tuple', AsyncMock()) + payload = SimpleNamespace(cached_message=None, data={'content': 'edited'}, channel_id=12, message_id=34) + await adapter.bot.on_raw_message_edit(payload) + channel.fetch_message.assert_awaited_once_with(34) + adapter._dispatch_gateway_tuple.assert_awaited_once_with('message_edit', (None, after), None) + adapter._dispatch_gateway_tuple.reset_mock() + channel.fetch_message.reset_mock() + payload.cached_message = after + await adapter.bot.on_raw_message_edit(payload) + payload.cached_message = None + payload.data = {'embeds': []} + await adapter.bot.on_raw_message_edit(payload) + payload.data = {'content': 'bot edit', 'author': {'bot': True}} + await adapter.bot.on_raw_message_edit(payload) + channel.fetch_message.assert_not_awaited() + adapter._dispatch_gateway_tuple.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)]) +async def test_guild_event_reply_uses_system_channel(monkeypatch, guild_id, expected_guild_id): + adapter = make_adapter() + channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=456))) + get_guild = Mock(return_value=SimpleNamespace(system_channel=channel)) + monkeypatch.setattr(adapter.bot, 'get_guild', get_guild) + adapter._get_channel = AsyncMock() + result = await adapter.send_message( + 'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + ) + assert result.message_id == 456 + get_guild.assert_called_once_with(expected_guild_id) + channel.send.assert_awaited_once_with(content='Welcome') + adapter._get_channel.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)]) +async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel( + monkeypatch, guild_id, expected_guild_id +): + adapter = make_adapter() + get_guild = Mock(return_value=SimpleNamespace(system_channel=None)) + monkeypatch.setattr(adapter.bot, 'get_guild', get_guild) + adapter._get_channel = AsyncMock() + with pytest.raises(ValueError, match='no system channel'): + await adapter.send_message( + 'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + ) + get_guild.assert_called_once_with(expected_guild_id) + adapter._get_channel.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index b11bed65c..fc07b24f8 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -155,6 +155,7 @@ def make_adapter(config: dict | None = None) -> LarkAdapter: }, DummyLogger(), ) + adapter.message_converter = LarkMessageConverter() adapter.api_client = DummyAPIClient() adapter.bot = DummyWSClient() return adapter @@ -322,6 +323,7 @@ async def test_lark_event_converter_maps_group_and_private_message(): @pytest.mark.asyncio async def test_lark_adapter_dispatches_and_caches_message_event(): adapter = make_adapter() + adapter._get_bot_open_id = AsyncMock(return_value='bot-open-id') calls: list[platform_events.Event] = [] async def listener(event, adapter): @@ -725,7 +727,9 @@ async def test_lark_streaming_card_uses_strictly_increasing_sequences(): final_request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0] assert final_request.request_body.sequence == 3 final_card = json.loads(final_request.request_body.card.data) - assert final_card['body']['elements'] == [{'tag': 'markdown', 'content': 'answer'}] + assert final_card['body']['elements'][0] == {'tag': 'markdown', 'content': 'answer'} + assert 'header' not in final_card + assert final_card['body']['elements'][-1]['columns'][1]['elements'][0]['content'] == '[LangBot](https://langbot.app?utm_source=feishu&utm_medium=bot_card&utm_campaign=langbot)' assert not final_card['config'].get('streaming_mode', False) assert 'response-1' not in adapter.card_id_dict assert 'stream-card-1' not in adapter.card_sequence_dict @@ -898,9 +902,9 @@ async def test_lark_webhook_dispatches_native_form_submission(): response = await adapter.handle_unified_webhook('bot-1', '', request) - assert response['toast'] == {'type': 'success', 'content': 'Submitted / 已提交'} + assert response['toast'] == {'type': 'success', 'content': 'Submitted'} assert response['card']['type'] == 'raw' - assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted / 已提交**' + assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted**' assert len(calls) == 1 assert calls[0].action == 'interaction.submitted' assert calls[0].data['callback_token'] == 'callback-token' @@ -929,7 +933,7 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): response = await adapter.handle_unified_webhook('bot-1', '', request) - assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}} + assert response == {'toast': {'type': 'success', 'content': 'Submitted'}} @pytest.mark.asyncio @@ -983,3 +987,41 @@ async def test_lark_webhook_rejects_wrong_signature(): response, status = await adapter.handle_unified_webhook('bot-1', '', request) assert status == 403 + + +def test_lark_stream_card_separates_multiple_reasoning_rounds(): + from langbot.pkg.platform.adapters.lark.stream_card import build_card, streaming_text + content = 'first thoughtfirst answersecond thoughtfinal answer' + preview = streaming_text(content) + assert '' not in preview and '' not in preview + card = build_card(content) + panel, answer, footer = card['body']['elements'] + assert panel['tag'] == 'collapsible_panel' + assert panel['expanded'] is False + assert panel['elements'][0]['content'] == 'first thought\n\nsecond thought' + assert answer['content'] == 'first answer\n\nfinal answer' + assert 'AI' in footer['columns'][0]['elements'][0]['content'] + assert footer['columns'][1]['elements'][0]['icon']['tag'] == 'custom_icon' + + +def test_lark_live_preview_is_bounded_but_final_reasoning_is_complete(): + from langbot.pkg.platform.adapters.lark.stream_card import build_card, streaming_text + thought = 'reasoning ' * 200 + content = '' + thought + '= 2) + assert len(manager.leases) == 1 + client.stop_typing.assert_not_awaited() + client.get_config.assert_awaited_once_with('peer', 'context') + client.stop_typing.assert_awaited_once_with('peer', 'ticket') + assert not manager.leases + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['error', 'cancel']) +async def test_processing_failure_stops_typing(failure): + manager, client = indicator() + + async def work(): + async with manager.processing('peer'): + await until(lambda: client.send_typing.await_count) + if failure == 'error': + raise ValueError('processing failed') + await asyncio.Event().wait() + + task = asyncio.create_task(work()) + await until(lambda: client.send_typing.await_count) + if failure == 'cancel': + task.cancel() + with pytest.raises(ValueError if failure == 'error' else asyncio.CancelledError): + await task + client.stop_typing.assert_awaited_once() + assert not manager.leases + + +@pytest.mark.asyncio +async def test_ticket_failure_is_backed_off_without_interrupting_work(): + manager, client = indicator() + client.get_config.side_effect = OSError('offline') + async with manager.processing('peer'): + await asyncio.sleep(0.03) + client.get_config.assert_awaited_once() + client.send_typing.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_shutdown_and_peer_isolation(): + manager, client = indicator() + async with manager.processing('one'), manager.processing('two'): + await until(lambda: client.send_typing.await_count >= 2) + await manager.close() + assert client.stop_typing.await_count == 2 + count = client.send_typing.await_count + async with manager.processing('three'): + await asyncio.sleep(0.01) + assert client.send_typing.await_count == count + assert not manager.leases + + +@pytest.mark.asyncio +async def test_stop_refreshes_expired_ticket(): + manager, client = indicator() + async with manager.processing('peer'): + await until(lambda: client.send_typing.await_count) + manager.cache['peer'] = ('old', 0, 600) + client.get_config.return_value.typing_ticket = 'fresh' + client.stop_typing.assert_awaited_once_with('peer', 'fresh') + + +@pytest.mark.asyncio +async def test_shutdown_does_not_cancel_stop_already_in_progress(): + manager, client = indicator() + stopping = asyncio.Event() + release = asyncio.Event() + + async def stop(*args): + stopping.set() + await release.wait() + + client.stop_typing.side_effect = stop + + async def work(): + async with manager.processing('peer'): + await until(lambda: client.send_typing.await_count) + + task = asyncio.create_task(work()) + await asyncio.wait_for(stopping.wait(), 1) + shutdown = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not shutdown.done() + release.set() + await asyncio.wait_for(asyncio.gather(task, shutdown), 1) + client.stop_typing.assert_awaited_once() + assert not manager.leases + + +@pytest.mark.asyncio +async def test_optional_adapter_hook(): + async with processing_indicator(object(), 'person', 'peer'): + pass + from langbot.pkg.platform.sources.openclaw_weixin import OpenClawWeixinAdapter + + manager, client = indicator() + adapter = OpenClawWeixinAdapter.model_construct(client=client) + adapter._typing_indicator = manager + async with processing_indicator(adapter, 'group', 'peer'): + await asyncio.sleep(0) + client.get_config.assert_not_awaited() + async with processing_indicator(adapter, 'person', 'peer'): + await until(lambda: client.send_typing.await_count) + client.stop_typing.assert_awaited_once() diff --git a/tests/unit_tests/platform/test_plugin_subscriptions.py b/tests/unit_tests/platform/test_plugin_subscriptions.py index b69bc9d4f..64f56aa57 100644 --- a/tests/unit_tests/platform/test_plugin_subscriptions.py +++ b/tests/unit_tests/platform/test_plugin_subscriptions.py @@ -1,6 +1,7 @@ """Independent bot subscriptions must not change primary route delivery.""" import asyncio +import contextvars from types import SimpleNamespace from unittest.mock import AsyncMock @@ -8,12 +9,18 @@ import pytest from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent from langbot.pkg.platform.botmgr import RuntimeBot +from langbot.pkg.persistence.tenant_uow import PersistenceScope, PersistenceScopeBoundary def make_bot(subscriptions): bot = object.__new__(RuntimeBot) - bot.bot_entity = SimpleNamespace(uuid='bot', event_bindings=[], plugin_processors=subscriptions) + bot.bot_entity = SimpleNamespace( + uuid='bot', adapter='aiocqhttp', event_bindings=[], plugin_processors=subscriptions + ) bot.execution_context = 'workspace' + bot.workspace_uuid = 'workspace' + bot.placement_generation = 1 + bot.ap = SimpleNamespace(workspace_service=SimpleNamespace(get_execution_binding=AsyncMock())) bot._record_adapter_event = AsyncMock() bot._record_event_route_trace = AsyncMock() bot.logger = SimpleNamespace(error=AsyncMock()) @@ -105,3 +112,47 @@ async def test_missing_processor_is_logged_without_unscoped_lookup(): await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'missing') bot.ap.agent_service.get_agent.assert_awaited_once_with('workspace', 'missing') assert bot._record_event_route_trace.await_args.kwargs['status'] == 'failed' + + +@pytest.mark.parametrize('primary_fails', [False, True]) +async def test_cloud_deliveries_own_their_scope_and_preserve_parent(primary_fails): + bot = make_bot([{'processor_uuid': 'plugin'}]) + bot.workspace_uuid = 'workspace' + active_scope = contextvars.ContextVar('test_event_scope', default=None) + active_transaction = contextvars.ContextVar('test_event_transaction', default=None) + + def tenant_scope(workspace): + return PersistenceScopeBoundary( + PersistenceScope.workspace(workspace), + active_scope=active_scope, + active_transaction=active_transaction, + ) + + bot.ap = SimpleNamespace( + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()), + persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='cloud_runtime'), tenant_scope=tenant_scope), + ) + seen = [] + + async def delivery(event, adapter, *args, **kwargs): + state = active_scope.get() + assert state.owner_task is asyncio.current_task() + assert state.scope == PersistenceScope.workspace('workspace') + seen.append(state) + await asyncio.sleep(0) + assert active_scope.get() is state + if primary_fails and not args: + raise ValueError('primary failed') + + bot._dispatch_eba_event_to_processor = delivery + bot._dispatch_plugin_subscription = delivery + async with tenant_scope('workspace'): + parent = active_scope.get() + await bot._handle_platform_event(MemberJoinedEvent(), None) + assert active_scope.get() is parent + assert parent.owner_task is asyncio.current_task() + assert active_scope.get() is None + assert len(seen) == 2 + assert seen[0] is not seen[1] + assert all(state is not parent and state.depth == 0 for state in seen) + assert bot.logger.error.await_count == int(primary_fails) diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 1a339c936..30455643d 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -38,6 +38,7 @@ class TestEventRouteTrace: bot = object.__new__(RuntimeBot) bot.bot_entity = SimpleNamespace( uuid='bot-1', + adapter='aiocqhttp', workspace_uuid=TEST_CONTEXT.workspace_uuid, event_bindings=event_bindings, ) @@ -170,7 +171,9 @@ class TestEventRouteTrace: agent_service=SimpleNamespace(get_agent=AsyncMock(side_effect=[malformed_agent, valid_agent])), agent_run_orchestrator=SimpleNamespace(run=fake_run), ) - event = SimpleNamespace(type='platform.member.joined') + from langbot_plugin.api.entities.builtin.platform.events import EBAEvent + + event = EBAEvent.model_construct(type='platform.member.joined') failed = await bot._dispatch_eba_event_to_processor(event, Mock()) delivered = await bot._dispatch_eba_event_to_processor(event, Mock()) @@ -642,7 +645,8 @@ class TestInteractionResumeRouting: send_message=AsyncMock(), ) - await bot._handle_interaction_submission(self._event(), adapter) + event = self._event() + await bot._handle_interaction_submission(event, adapter) envelope, binding, adapter_context = captured[0] assert envelope.event_type == 'interaction.submitted' @@ -650,7 +654,11 @@ class TestInteractionResumeRouting: assert binding.binding_id == 'binding-1' assert binding.processor_type == 'agent' assert binding.processor_id == 'agent-1' - assert adapter_context == {'_delivery_adapter': adapter} + assert adapter_context == { + '_delivery_adapter': adapter, + '_platform_event': event, + '_execution_context': TEST_CONTEXT, + } assert envelope.delivery.supports_streaming is False assert binding.delivery_policy.enable_reply is False adapter.send_message.assert_not_awaited() @@ -701,7 +709,10 @@ async def test_installed_event_processor_never_receives_unbound_events(): from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent bot = TestEventRouteTrace._make_bot([]) - bot.ap = SimpleNamespace(plugin_connector=SimpleNamespace(emit_event=AsyncMock())) + bot.ap = SimpleNamespace( + workspace_service=active_workspace_service(), + plugin_connector=SimpleNamespace(emit_event=AsyncMock()), + ) bot._record_adapter_event = AsyncMock() await bot._handle_platform_event(MemberJoinedEvent(), Mock()) bot.ap.plugin_connector.emit_event.assert_not_called() @@ -767,6 +778,7 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_ """Draining runner results must not send text, duplicate replies, or stream cards.""" from langbot_plugin.api.entities.builtin.platform import entities, events, message from langbot_plugin.api.entities.builtin.provider import message as provider_message + from datetime import datetime from langbot.pkg.agent.runner.platform_tools import execute_platform_tool, freeze_platform_context @@ -783,6 +795,10 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_ completed = [] async def run(envelope, binding, adapter_context): + if kind == 'event_processor': + restored = message.MessageChain.model_validate(envelope.data['message_chain']) + assert restored.source.id == 'source-1' + assert restored.source.time == datetime(2026, 10, 8, 12) assert envelope.delivery.supports_streaming is False assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_reply is False @@ -823,7 +839,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_ ) event = events.MessageReceivedEvent( message_id='message-1', - message_chain=message.MessageChain([message.Plain(text='hello')]), + message_chain=message.MessageChain( + [message.Source(id='source-1', time=datetime(2026, 10, 8, 12)), message.Plain(text='hello')] + ), sender=entities.User(id='user-1', nickname='QA'), chat_type=entities.ChatType.PRIVATE, chat_id='user-1', diff --git a/tests/unit_tests/platform/test_slack_eba_adapter.py b/tests/unit_tests/platform/test_slack_eba_adapter.py index 0b8f7deac..faa1816b2 100644 --- a/tests/unit_tests/platform/test_slack_eba_adapter.py +++ b/tests/unit_tests/platform/test_slack_eba_adapter.py @@ -164,12 +164,7 @@ async def test_slack_message_converter_maps_common_components_to_text(): ) ) - assert 'hi' in text - assert '<@U-2>' in text - assert '' in text - assert 'https://example.test/a.png' in text - assert 'a.txt' in text - assert 'quoted' in text + assert text == 'hi\n<@U-2>\n\nhttps://example.test/a.png\na.txt\nquoted' @pytest.mark.asyncio @@ -190,7 +185,7 @@ async def test_slack_event_converter_maps_private_group_and_platform_specific(): assert isinstance(group_event.message_chain[1], platform_message.At) assert isinstance(platform_event, platform_events.PlatformSpecificEvent) - assert platform_event.action == 'slack.file_share' + assert platform_event.action == 'slack.message' @pytest.mark.asyncio @@ -212,7 +207,7 @@ async def test_slack_adapter_dispatches_eba_and_legacy_and_caches_group_event(): assert len(eba_calls) == 1 assert len(legacy_calls) == 1 received = eba_calls[0] - assert await adapter.get_message('group', 'C-1', 'evt-1') == received + assert await adapter.get_message('group', 'C-1', '1710003600.000100') == received assert (await adapter.get_group_info('C-1')).id == 'C-1' assert (await adapter.get_group_member_info('C-1', 'U-1')).user.id == 'U-1' @@ -225,7 +220,7 @@ async def test_slack_send_reply_platform_api_and_unsupported(): reply_result = await adapter.reply_message( source_event, platform_message.MessageChain([platform_message.Plain(text='reply')]) ) - assert reply_result.message_id == 'evt-1' + assert reply_result.message_id == '1.2' assert ('person', 'U-1', 'reply') in adapter.bot.sent await adapter.send_message( @@ -235,6 +230,7 @@ async def test_slack_send_reply_platform_api_and_unsupported(): assert await adapter.call_platform_api('get_mode', {}) == { 'webhook': True, + 'socket_mode': False, 'bot_account_id': 'B-1', } assert await adapter.call_platform_api('auth_test', {}) == {'ok': True, 'user_id': 'B-1'} diff --git a/tests/unit_tests/platform/test_slack_events.py b/tests/unit_tests/platform/test_slack_events.py new file mode 100644 index 000000000..733bdf8ab --- /dev/null +++ b/tests/unit_tests/platform/test_slack_events.py @@ -0,0 +1,200 @@ +import asyncio +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock +from urllib.parse import urlencode + +import pytest +from slack_sdk.signature import SignatureVerifier + +from langbot.libs.slack_api.api import SlackClient +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter +from langbot.pkg.api.http.service.slack_setup import manifest +from langbot.libs.slack_api.media import send_chain +from langbot_plugin.api.entities.builtin.platform import message as pm + + +def client(): + return SlackClient('test', 'secret', SimpleNamespace(error=AsyncMock(), warning=AsyncMock()), True) + + +async def test_thread_text_and_file_use_the_same_channel_and_thread(): + bot = client() + bot.client = SimpleNamespace( + chat_postMessage=AsyncMock(return_value={'ok': True, 'ts': '2.1'}), + files_upload_v2=AsyncMock(return_value={'ok': True, 'files': [{'id': 'F1'}]}), + ) + await send_chain( + bot, + 'channel', + 'D1', + pm.MessageChain( + [ + pm.Plain(text='reply'), + pm.File(name='test.txt', base64='dGVzdA=='), + ] + ), + thread_ts='1.1', + ) + bot.client.chat_postMessage.assert_awaited_once_with(channel='D1', text='reply', thread_ts='1.1') + assert bot.client.files_upload_v2.call_args.kwargs['thread_ts'] == '1.1' + assert bot.client.files_upload_v2.call_args.kwargs['channel'] == 'D1' + + +async def test_http_backpressure_does_not_mark_event_seen(): + bot = client() + for index in range(100): + assert bot._enqueue({'index': index}, str(index)) + assert not bot._enqueue({'event_id': 'overflow'}, 'overflow') + assert 'overflow' not in bot.socket_seen + await bot.close_socket() + + +@pytest.mark.parametrize( + ('raw', 'expected'), + [ + ( + {'type': 'message', 'subtype': 'message_changed', 'message': {'ts': '1.1', 'text': 'new', 'user': 'U1'}}, + 'message.edited', + ), + ({'type': 'message', 'subtype': 'message_deleted', 'deleted_ts': '1.1'}, 'message.deleted'), + ( + {'type': 'reaction_added', 'user': 'U1', 'reaction': 'thumbsup', 'item': {'type': 'message', 'ts': '1.1'}}, + 'message.reaction', + ), + ( + { + 'type': 'reaction_removed', + 'user': 'U1', + 'reaction': 'thumbsup', + 'item': {'type': 'message', 'ts': '1.1'}, + }, + 'message.reaction', + ), + ({'type': 'member_joined_channel', 'user': 'U1'}, 'group.member_joined'), + ({'type': 'member_left_channel', 'user': 'U1'}, 'group.member_left'), + ({'type': 'member_joined_channel', 'user': 'UBOT', 'inviter': 'U1'}, 'bot.invited_to_group'), + ({'type': 'member_left_channel', 'user': 'UBOT'}, 'bot.removed_from_group'), + ({'type': 'channel_rename', 'channel': {'id': 'C1', 'name': 'new'}}, 'group.info_updated'), + ({'type': 'group_archive'}, 'group.info_updated'), + ({'type': 'message', 'subtype': 'channel_topic', 'topic': 'new'}, 'group.info_updated'), + ({'type': 'app_home_opened', 'user': 'U1', 'tab': 'home'}, 'platform.specific'), + ({'type': 'app_uninstalled'}, 'platform.specific'), + ({'type': 'tokens_revoked', 'tokens': {'bot': ['UBOT']}}, 'platform.specific'), + ], +) +async def test_standard_events(raw, expected): + event = await SlackEventConverter(bot_user_id='UBOT').target2yiri( + SlackEvent({'event_id': 'delivery', 'event': {'channel': 'C1', 'channel_type': 'channel', **raw}}) + ) + assert event.type == expected + if expected.startswith('message.'): + assert event.message_id == '1.1' + assert event.chat_id == 'C1' + if expected == 'message.reaction': + assert event.is_add == (raw['type'] == 'reaction_added') + if expected == 'message.deleted': + assert event.operator is None + if expected == 'message.edited': + assert 'new' in str(event.new_content) + if raw['type'] == 'channel_rename': + assert event.group.name == 'new' + assert event.changed_fields == ['name'] + + +async def test_channel_messages_are_not_duplicate_mentions_or_new_messages_on_edit(): + bot = client() + bot._handle_message = AsyncMock() + await bot.handle_payload({'event': {'type': 'message', 'channel_type': 'channel', 'text': '<@BOT> hello'}}) + bot._handle_message.assert_not_awaited() + await bot.handle_payload({'event': {'type': 'app_mention', 'channel': 'C1', 'text': '<@BOT> hello'}}) + assert bot._handle_message.await_count == 1 + await bot.handle_payload({'event': {'type': 'message', 'channel_type': 'im', 'subtype': 'message_changed'}}) + assert bot._handle_message.call_args.args[1] == 'event' + + +@pytest.mark.parametrize('kind', ['block_actions', 'view_submission', 'view_closed', 'shortcut', 'message_action']) +async def test_interactions_dispatched_and_sanitized(kind): + bot = client() + handler = AsyncMock() + bot.on_message('event')(handler) + payload = {'type': kind, 'token': 'secret', 'response_url': 'secret-url', 'user': {'id': 'U1'}} + await bot.handle_payload(payload) + event = await SlackEventConverter().target2yiri(handler.call_args.args[0]) + assert event.action == f'slack.{kind}' + assert 'token' not in event.data and 'response_url' not in event.data + + +async def test_http_signature_fast_ack_form_and_retry_dedup(): + bot = client() + gate = asyncio.Event() + + async def process(_): + await gate.wait() + + bot.handle_payload = AsyncMock(side_effect=process) + + def request(body, mime): + stamp = str(int(time.time())) + return SimpleNamespace( + get_data=AsyncMock(return_value=body), + mimetype=mime, + headers={ + 'X-Slack-Request-Timestamp': stamp, + 'X-Slack-Signature': SignatureVerifier('secret').generate_signature(timestamp=stamp, body=body), + }, + ) + + body = urlencode({'payload': json.dumps({'type': 'block_actions'})}).encode() + try: + async with bot.app.app_context(): + assert await bot._handle_callback_internal(request(body, 'application/x-www-form-urlencoded')) == ('', 200) + invalid = request(body, 'application/x-www-form-urlencoded') + invalid.headers['X-Slack-Signature'] = 'invalid' + assert (await bot._handle_callback_internal(invalid))[1] == 401 + event_body = json.dumps({'event_id': 'E1', 'event': {'type': 'app_home_opened'}}).encode() + await bot._handle_callback_internal(request(event_body, 'application/json')) + await bot._handle_callback_internal(request(event_body, 'application/json')) + assert bot.socket_queue.qsize() == 2 + finally: + gate.set() + await bot.close_socket() + + +async def test_socket_interactivity_ack_and_delivery(): + bot = client() + socket = SimpleNamespace(send_socket_mode_response=AsyncMock()) + payload = {'type': 'view_submission'} + request = SimpleNamespace(type='interactive', envelope_id='E1', payload=payload) + await bot._socket_request(socket, request) + await bot._socket_request(socket, request) + assert bot.socket_queue.qsize() == 1 + socket.send_socket_mode_response.assert_awaited() + assert await bot.socket_queue.get() == payload + + +@pytest.mark.parametrize('socket', [True, False]) +def test_setup_complete_manifest(socket): + result = manifest( + 'LangBot', 'https://example.com/bots/1', 'https://example.com/callback', events=True, socket_mode=socket + ) + settings = result['settings'] + assert { + 'message.channels', + 'message.groups', + 'message.mpim', + 'reaction_added', + 'reaction_removed', + 'member_joined_channel', + 'member_left_channel', + 'app_home_opened', + 'app_uninstalled', + 'tokens_revoked', + } <= set(settings['event_subscriptions']['bot_events']) + assert 'reactions:read' in result['oauth_config']['scopes']['bot'] + assert settings['interactivity']['is_enabled'] + assert ('request_url' in settings['interactivity']) is not socket + assert ('request_url' in settings['event_subscriptions']) is not socket + assert 'interactivity' not in manifest('LangBot', '', '', events=False, socket_mode=socket)['settings'] diff --git a/tests/unit_tests/platform/test_slack_media.py b/tests/unit_tests/platform/test_slack_media.py new file mode 100644 index 000000000..3238c3e7c --- /dev/null +++ b/tests/unit_tests/platform/test_slack_media.py @@ -0,0 +1,207 @@ +import base64 +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from langbot.libs.slack_api.api import SlackClient +from langbot.libs.slack_api.media import attachment_bytes, download_media, send_chain +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.message_converter import SlackMessageConverter +from langbot_plugin.api.entities.builtin.platform import message as pm + + +def sdk_response(data): + return AsyncSlackResponse( + client=None, + http_verb='POST', + api_url='https://slack.com/api/test', + req_args={}, + data=data, + headers={}, + status_code=200, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target_type,target_id', [('person', 'U123'), ('channel', 'C123')]) +async def test_real_sdk_responses_for_text_and_attachment(target_type, target_id): + client = SlackClient('test-token', '', SimpleNamespace(error=AsyncMock()), unified_mode=True) + text_data = {'ok': True, 'channel': 'C123', 'message': {'ts': '1.2', 'bot_id': 'B123'}} + file_data = {'ok': True, 'files': [{'id': 'F123'}]} + client.client = SimpleNamespace( + chat_postMessage=AsyncMock(return_value=sdk_response(text_data)), + conversations_open=AsyncMock(return_value=sdk_response({'ok': True, 'channel': {'id': 'D123'}})), + files_upload_v2=AsyncMock(return_value=sdk_response(file_data)), + ) + result = await send_chain( + client, + target_type, + target_id, + pm.MessageChain( + [ + pm.Plain(text='picture'), + pm.File(name='image.png', base64='dGVzdA=='), + ] + ), + ) + assert result['responses'] == [text_data, file_data] + assert client.client.chat_postMessage.await_count == 1 + assert client.client.files_upload_v2.await_count == 1 + + +def bot(): + return SimpleNamespace( + bot_token='test-token', + client=SimpleNamespace( + conversations_open=AsyncMock(return_value={'ok': True, 'channel': {'id': 'D123'}}), + files_upload_v2=AsyncMock(return_value={'ok': True, 'files': [{'id': 'F123'}]}), + ), + send_message_to_one=AsyncMock(return_value={'ok': True}), + send_message_to_channel=AsyncMock(return_value={'ok': True}), + ) + + +@pytest.mark.asyncio +async def test_svg_base64_is_uploaded_to_dm_not_sent_as_filename(): + client = bot() + svg = b'' + await send_chain( + client, + 'person', + 'U123', + pm.MessageChain( + [ + pm.Plain(text='Here is the picture'), + pm.File(name='moon.svg', base64=base64.b64encode(svg).decode()), + ] + ), + ) + client.send_message_to_one.assert_awaited_once_with('Here is the picture', 'U123') + client.client.conversations_open.assert_awaited_once_with(users=['U123']) + client.client.files_upload_v2.assert_awaited_once_with( + channel='D123', file=svg, filename='moon.svg', title='moon.svg' + ) + + +@pytest.mark.asyncio +async def test_multiple_attachments_to_channel_preserve_bytes_and_text_order(tmp_path): + client = bot() + path = tmp_path / 'notes.txt' + path.write_bytes(b'hello') + await send_chain( + client, + 'channel', + 'C123', + pm.MessageChain( + [ + pm.Image(base64='data:image/png;base64,' + base64.b64encode(b'png bytes').decode()), + pm.File(path=str(path)), + pm.Plain(text='done'), + ] + ), + ) + calls = client.client.files_upload_v2.await_args_list + assert [c.kwargs['file'] for c in calls] == [b'png bytes', b'hello'] + assert [c.kwargs['filename'] for c in calls] == ['attachment.png', 'notes.txt'] + client.client.conversations_open.assert_not_awaited() + client.send_message_to_channel.assert_awaited_once_with('done', 'C123') + + +@pytest.mark.asyncio +async def test_upload_failure_is_not_reported_as_success(): + client = bot() + client.client.files_upload_v2.return_value = {'ok': False, 'error': 'missing_scope'} + with pytest.raises(RuntimeError, match='missing_scope'): + await send_chain( + client, + 'channel', + 'C123', + pm.MessageChain( + [ + pm.File(name='test.txt', base64='dGVzdA=='), + ] + ), + ) + + +@pytest.mark.asyncio +async def test_missing_attachment_data_fails_instead_of_faking_delivery(): + with pytest.raises(ValueError, match='no base64'): + await attachment_bytes(pm.File(name='missing.txt')) + + +@pytest.mark.asyncio +async def test_local_bytes_take_priority_over_private_url(): + data, name = await attachment_bytes( + pm.File(name='file.txt', url='https://files.slack.com/private', base64='dGVzdA==') + ) + assert (data, name) == (b'test', 'file.txt') + + +@pytest.mark.asyncio +async def test_received_multiple_files_keep_type_and_authenticated_bytes(): + event = SlackEvent( + { + 'event': { + 'channel_type': 'im', + 'text': 'attachments', + 'files': [ + {'id': 'F1', 'name': 'a.png', 'mimetype': 'image/png', 'url_private': 'https://files.slack.com/a'}, + { + 'id': 'F2', + 'name': 'b.pdf', + 'mimetype': 'application/pdf', + 'url_private': 'https://files.slack.com/b', + }, + ], + } + } + ) + with patch( + 'langbot.pkg.platform.adapters.slack.message_converter.download_media', + new=AsyncMock(return_value=(b'data', 'application/octet-stream')), + ) as download: + chain = await SlackMessageConverter.target2yiri(event, 'test-token') + assert isinstance(chain[1], pm.Image) + assert chain[1].base64 == 'data:image/png;base64,ZGF0YQ==' + assert isinstance(chain[2], pm.File) and chain[2].name == 'b.pdf' + assert chain[2].base64 == 'ZGF0YQ==' + assert chain[3].text == 'attachments' + assert download.await_count == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'url,authenticated', + [ + ('https://files.slack.com/file', True), + ('https://example.com/file', False), + ('https://files.slack.com.evil.test/file', False), + ], +) +async def test_download_credentials_are_scoped_to_slack(url, authenticated): + response = SimpleNamespace(status=200, headers={'Content-Type': 'image/png'}) + context = AsyncMock() + context.__aenter__.return_value = response + with ( + patch('langbot.libs.slack_api.media.httpclient.get_session') as session, + patch('langbot.libs.slack_api.media.httpclient.read_limited', new=AsyncMock(return_value=b'png')), + ): + session.return_value.get.return_value = context + assert await download_media(url, 'test-token') == (b'png', 'image/png') + kwargs = session.return_value.get.call_args.kwargs + assert bool(kwargs['headers']) == authenticated + if authenticated: + assert kwargs['allow_redirects'] is False + + +@pytest.mark.asyncio +async def test_download_http_failure_is_visible(): + context = AsyncMock() + context.__aenter__.return_value = SimpleNamespace(status=403) + with patch('langbot.libs.slack_api.media.httpclient.get_session') as session: + session.return_value.get.return_value = context + with pytest.raises(ValueError, match='HTTP 403'): + await download_media('https://files.slack.com/file', 'test-token') diff --git a/tests/unit_tests/platform/test_slack_socket.py b/tests/unit_tests/platform/test_slack_socket.py new file mode 100644 index 000000000..80e13b08b --- /dev/null +++ b/tests/unit_tests/platform/test_slack_socket.py @@ -0,0 +1,63 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +import asyncio + +import pytest + +from langbot.libs.slack_api.api import SlackClient + + +@pytest.mark.asyncio +async def test_socket_ack_dedup_and_backpressure(): + bot = SlackClient('test', '', SimpleNamespace(warning=AsyncMock(), error=AsyncMock()), unified_mode=True) + client = SimpleNamespace(send_socket_mode_response=AsyncMock()) + req = SimpleNamespace(type='events_api', envelope_id='envelope', payload={'event_id': 'event'}) + await bot._socket_request(client, req) + await bot._socket_request(client, req) + assert client.send_socket_mode_response.await_count == 2 + assert bot.socket_queue.qsize() == 1 + for i in range(99): + bot.socket_queue.put_nowait({'id': i}) + req.payload = {'event_id': 'another'} + await bot._socket_request(client, req) + assert client.send_socket_mode_response.await_count == 2 + assert 'another' not in bot.socket_seen + await bot.close_socket() + assert bot.socket_queue.empty() + + +@pytest.mark.asyncio +async def test_bot_events_do_not_loop(): + bot = SlackClient('test', '', SimpleNamespace(), unified_mode=True) + bot._handle_message = AsyncMock() + await bot.handle_payload({'event': {'bot_id': 'other-bot', 'channel_type': 'im'}}) + bot._handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_socket_lifecycle_closes_sdk_and_worker(monkeypatch): + connected = asyncio.Event() + client = SimpleNamespace( + socket_mode_request_listeners=[], connect=AsyncMock(side_effect=connected.set), close=AsyncMock() + ) + monkeypatch.setattr('slack_sdk.socket_mode.aiohttp.SocketModeClient', lambda **kwargs: client) + bot = SlackClient('test', '', SimpleNamespace(info=AsyncMock()), unified_mode=True) + task = asyncio.create_task(bot.run_socket('app-token')) + await asyncio.wait_for(connected.wait(), 1) + await bot.close_socket() + await asyncio.wait_for(task, 1) + client.close.assert_awaited_once() + assert bot.socket_worker is None + + +@pytest.mark.asyncio +async def test_socket_connect_failure_cleans_resources(monkeypatch): + client = SimpleNamespace( + socket_mode_request_listeners=[], connect=AsyncMock(side_effect=ValueError('invalid_auth')), close=AsyncMock() + ) + monkeypatch.setattr('slack_sdk.socket_mode.aiohttp.SocketModeClient', lambda **kwargs: client) + bot = SlackClient('test', '', SimpleNamespace(info=AsyncMock()), unified_mode=True) + with pytest.raises(ValueError): + await bot.run_socket('app-token') + client.close.assert_awaited_once() + assert bot.socket_worker is None diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index 8fa5f7617..24761fa8e 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -402,7 +402,10 @@ async def test_telegram_converter_maps_non_message_updates(): } } ) - reaction_event = await TelegramEventConverter.target2yiri(reaction, make_adapter().bot, 'test_bot') + adapter = make_adapter() + reaction_handler = next(h for h in adapter.application.handlers[0] if isinstance(h, MessageReactionHandler)) + assert reaction_handler.check_update(reaction) is True + reaction_event = await TelegramEventConverter.target2yiri(reaction, adapter.bot, 'test_bot') assert isinstance(reaction_event, platform_events.MessageReactionEvent) assert reaction_event.reaction == '👍' assert reaction_event.is_add is True @@ -471,6 +474,7 @@ async def test_telegram_converter_maps_bot_status_events(): ) muted_event = await TelegramEventConverter.target2yiri(muted, make_adapter().bot, 'test_bot') assert isinstance(muted_event, platform_events.BotMutedEvent) + assert muted_event.duration is None unmuted = make_update( { @@ -489,6 +493,30 @@ async def test_telegram_converter_maps_bot_status_events(): unmuted_event = await TelegramEventConverter.target2yiri(unmuted, make_adapter().bot, 'test_bot') assert isinstance(unmuted_event, platform_events.BotUnmutedEvent) + partial_unmute = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': restricted_member, + 'new_chat_member': {**restricted_member, 'can_send_messages': True}, + } + } + ) + partial_event = await TelegramEventConverter.target2yiri(partial_unmute, make_adapter().bot, 'test_bot') + assert isinstance(partial_event, platform_events.BotUnmutedEvent) + + removed = make_update( + { + 'my_chat_member': { + **base_member, + 'old_chat_member': restricted_member, + 'new_chat_member': {'user': restricted_member['user'], 'status': 'kicked', 'until_date': 0}, + } + } + ) + removed_event = await TelegramEventConverter.target2yiri(removed, make_adapter().bot, 'test_bot') + assert isinstance(removed_event, platform_events.BotRemovedFromGroupEvent) + @pytest.mark.asyncio async def test_telegram_reply_message_sends_text_image_and_file_components(): @@ -576,6 +604,17 @@ async def test_telegram_platform_apis_call_underlying_bot_methods(): assert invite['invite_link'] == 'https://t.me/+abc' assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True} + bot.pin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2, disable_notification=False) + bot.unpin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2) + bot.unpin_all_chat_messages.assert_awaited_once_with(chat_id=1) + bot.get_chat_administrators.assert_awaited_once_with(chat_id=1) + bot.set_chat_title.assert_awaited_once_with(chat_id=1, title='New') + bot.set_chat_description.assert_awaited_once_with(chat_id=1, description='Desc') + bot.get_chat_member_count.assert_awaited_once_with(chat_id=1) + bot.send_chat_action.assert_awaited_once_with(chat_id=1, action='typing') + bot.create_chat_invite_link.assert_awaited_once_with(chat_id=1, name='invite', expire_date=None, member_limit=None) + bot.answer_callback_query.assert_awaited_once_with(callback_query_id='cb', text=None, show_alert=False, url=None) + @pytest.mark.asyncio async def test_telegram_unmute_member_uses_current_chat_permissions_fields(): @@ -627,3 +666,152 @@ def test_runtime_bot_maps_telegram_eba_events_to_plugin_events(): plugin_event = RuntimeBot._eba_event_to_plugin_event(platform_event) assert isinstance(plugin_event, plugin_event_type) assert plugin_event.model_dump()['event_name'] == plugin_event_type.__name__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize('with_text', [True, False]) +async def test_stream_completion_delivers_attachments_once(with_text): + adapter = make_adapter() + bot = SimpleNamespace( + send_message=AsyncMock(return_value=SimpleNamespace(message_id=900)), + edit_message_text=AsyncMock(), + send_photo=AsyncMock(return_value=SimpleNamespace(message_id=901)), + send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)), + ) + object.__setattr__(adapter, 'bot', bot) + adapter.msg_stream_id['stream'] = ('message', 999, True) + source = platform_events.MessageReceivedEvent( + message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)}) + ) + parts = [platform_message.Plain(text='done')] if with_text else [] + parts.extend( + [ + platform_message.Image(base64='data:image/png;base64,' + base64.b64encode(b'image').decode()), + platform_message.File( + name='result.txt', base64='data:text/plain;base64,' + base64.b64encode(b'result').decode() + ), + ] + ) + chunk = SimpleNamespace(resp_message_id='stream', msg_sequence=2, tool_calls=None) + chain = platform_message.MessageChain(parts) + await adapter.reply_message_chunk(source, chunk, chain, is_final=False) + bot.send_photo.assert_not_awaited() + bot.send_document.assert_not_awaited() + await adapter.reply_message_chunk(source, chunk, chain, quote_origin=True, is_final=True) + await adapter.reply_message_chunk(source, chunk, chain, quote_origin=True, is_final=True) + bot.send_photo.assert_awaited_once() + bot.send_document.assert_awaited_once() + assert bot.send_document.await_args.kwargs['document'].filename == 'result.txt' + assert bot.send_photo.await_args.kwargs['reply_to_message_id'] == 88 + assert 'stream' not in adapter.msg_stream_id + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reply', [False, True]) +async def test_telegram_send_returns_message_result(reply): + adapter = make_adapter() + bot = SimpleNamespace(send_message=AsyncMock(return_value=SimpleNamespace(message_id=901))) + object.__setattr__(adapter, 'bot', bot) + chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + if reply: + source = platform_events.MessageReceivedEvent( + message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)}) + ) + result = await adapter.reply_message(source, chain, quote_origin=True) + assert bot.send_message.await_args.kwargs['reply_to_message_id'] == 88 + else: + result = await adapter.send_message('group', '-100123#7', chain) + assert bot.send_message.await_args.kwargs['message_thread_id'] == 7 + assert isinstance(result, platform_events.MessageResult) + assert result.message_id == 901 + assert result.raw == {'message_ids': [901]} + + +@pytest.mark.parametrize('until, expected', [(0, None), (1060, 60), (900, 0)]) +def test_restriction_duration_uses_event_time(until, expected): + from langbot.pkg.platform.adapters.telegram.event_converter import _restriction_duration + + assert ( + _restriction_duration( + datetime.datetime.fromtimestamp(until, datetime.UTC), + datetime.datetime.fromtimestamp(1000, datetime.UTC), + ) + == expected + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('new_reaction', [[], [{'type': 'emoji', 'emoji': '❤'}]]) +async def test_reaction_removal_keeps_removed_emoji(new_reaction): + update = make_update( + { + 'message_reaction': { + 'chat': {'id': -1001, 'type': 'supergroup', 'title': 'Group'}, + 'message_id': 77, + 'date': 1000, + 'user': {'id': 3, 'is_bot': False, 'first_name': 'Reactor'}, + 'old_reaction': [{'type': 'emoji', 'emoji': '👍'}, *new_reaction], + 'new_reaction': new_reaction, + } + } + ) + event = await TelegramEventConverter.target2yiri(update, make_adapter().bot, 'test_bot') + assert event.reaction == '👍' + assert event.is_add is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reply', [False, True]) +async def test_rejected_photo_falls_back_without_resending_other_attachments(reply): + adapter = make_adapter() + bot = SimpleNamespace( + send_photo=AsyncMock(side_effect=telegram.error.BadRequest('Image_process_failed')), + send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)), + ) + object.__setattr__(adapter, 'bot', bot) + chain = platform_message.MessageChain( + [ + platform_message.File(name='result.txt', base64='data:text/plain;base64,cmVzdWx0'), + platform_message.Image(base64='data:image/png;base64,aW1hZ2U='), + ] + ) + if reply: + source = platform_events.MessageReceivedEvent( + message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)}) + ) + result = await adapter.reply_message(source, chain, quote_origin=True) + else: + result = await adapter.send_message('private', '123', chain) + assert result.message_id == 902 + assert bot.send_document.await_count == 2 + assert [call.kwargs['document'].filename for call in bot.send_document.await_args_list] == ['result.txt', 'image'] + assert bot.send_document.await_args.kwargs['document'].input_file_content == b'image' + if reply: + assert bot.send_document.await_args.kwargs['reply_to_message_id'] == 88 + + +@pytest.mark.asyncio +async def test_attachment_only_completion_removes_thinking_placeholder(): + adapter = make_adapter() + object.__setattr__( + adapter, + 'bot', + SimpleNamespace( + send_document=AsyncMock(return_value=SimpleNamespace(message_id=902)), + delete_message=AsyncMock(), + ), + ) + adapter.msg_stream_id['stream'] = ('message', 999, False) + source = platform_events.MessageReceivedEvent( + message_id=88, source_platform_object=make_update({'message': base_message_payload(message_id=88)}) + ) + await adapter.reply_message_chunk( + source, + SimpleNamespace(resp_message_id='stream', msg_sequence=1, tool_calls=None), + platform_message.MessageChain( + [platform_message.File(name='result.txt', base64='data:text/plain;base64,cmVzdWx0')] + ), + is_final=True, + ) + adapter.bot.delete_message.assert_awaited_once_with(chat_id=123, message_id=999) + assert 'stream' not in adapter.msg_stream_id diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py index 3274626bf..9504ca251 100644 --- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -90,17 +90,23 @@ async def test_image_defaults_to_png(): adapter, _, _ = _make_adapter() chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}] await adapter._process_image_components(_make_connection(), chain) - assert chain[0]['base64'].startswith('data:image/png;base64,') + assert chain[0]['base64'] == 'data:image/png;base64,aGVsbG8=' @pytest.mark.asyncio -async def test_voice_uses_guessed_or_wav_mimetype(): +async def test_voice_uses_guessed_or_wav_mimetype(monkeypatch): adapter, storage_mgr, _ = _make_adapter() - chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}] + path = f'{_UPLOAD_PREFIX}clip.unknown' + chain = [{'type': 'Voice', 'path': path}] + guess_type = Mock(return_value=(None, None)) + monkeypatch.setattr('mimetypes.guess_type', guess_type) + await adapter._process_image_components(_make_connection(), chain) - assert chain[0]['base64'].startswith('data:audio/') + + guess_type.assert_called_once_with(path) + assert chain[0]['base64'] == 'data:audio/wav;base64,aGVsbG8=' assert chain[0]['path'] == '' - storage_mgr.delete_scoped_object_key.assert_awaited_once() + storage_mgr.delete_scoped_object_key.assert_awaited_once_with(_CONTEXT, path, expected_owner_type='upload_image') @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py index 18dad8a29..d6c123695 100644 --- a/tests/unit_tests/platform/test_websocket_session_isolation.py +++ b/tests/unit_tests/platform/test_websocket_session_isolation.py @@ -596,7 +596,8 @@ def test_widget_sends_stable_session_id_to_all_conversation_endpoints(): assert 'window.sessionStorage' in widget assert 'window.localStorage' not in widget assert 'session_id=' in widget - assert widget.count('encodeURIComponent(state.sessionId)') >= 3 + assert widget.count('encodeURIComponent(state.sessionId)') >= 2 + assert 'endpoint.searchParams.set("session_id", state.sessionId)' in widget assert 'loadHistory(true)' in widget assert 'messageVersion !== state.messageVersion' in widget assert 'scheduleHistoryReload();' in widget diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index 1952505da..4e8fccc9b 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -241,7 +241,7 @@ async def test_wecombot_event_converter_maps_private_and_group_messages_to_eba() assert private_event.adapter_name == 'wecombot-omni' assert private_event.chat_type == platform_entities.ChatType.PRIVATE assert private_event.chat_id == 'user-1' - assert str(private_event.message_chain) == 'hello' + assert str(private_event.message_chain) == '@EBA Bot hello' assert isinstance(group_event, platform_events.MessageReceivedEvent) assert group_event.chat_type == platform_entities.ChatType.GROUP @@ -302,7 +302,9 @@ async def test_wecombot_send_reply_feedback_and_platform_api_use_underlying_clie source_event = await WecomBotEventConverter().target2yiri(wecombot_event()) await adapter.reply_message(source_event, message) - adapter.bot.reply_text.assert_awaited_once_with('req-1', 'hello') + adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True) + adapter.bot.reply_text.assert_not_awaited() + adapter.bot.push_stream_chunk.reset_mock() await adapter.reply_message_chunk(source_event, None, message, is_final=True) adapter.bot.push_stream_chunk.assert_awaited_once_with('msg-1', 'hello', is_final=True) @@ -328,3 +330,170 @@ async def test_wecombot_webhook_mode_rejects_proactive_send(): await adapter.send_message( 'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]) ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['enter_chat', 'template_card_event']) +async def test_business_events_preserve_identity_payload_and_deduplicate(kind): + from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient + + adapter = make_adapter() + listener = AsyncMock() + adapter.listeners[platform_events.EBAEvent] = listener + client = WecomBotWsClient('bot-id', 'secret', DummyLogger()) + client.on_message('event')(adapter._handle_native_event) + client.on_message('template_card_event')(adapter._handle_interaction_event) + body = { + 'msgid': 'business-event-1', + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor-1', 'alias': 'Visitor'}, + 'create_time': 1700000000, + 'event': { + 'eventtype': kind, + 'template_card_event': { + 'task_id': 'business-card', + 'event_key': 'approve', + 'card_type': 'button_interaction', + 'selected_items': [{'question_key': 'choice', 'option_ids': ['yes']}], + }, + }, + } + frame = {'headers': {'req_id': 'callback-1'}, 'body': body} + await client._handle_event_callback(frame) + await client._handle_event_callback(frame) + listener.assert_awaited_once() + event = listener.await_args.args[0] + assert event.type == 'wecombot.' + kind + assert event.user.id == 'visitor-1' + assert event.chat_id == 'visitor-1' + assert event.timestamp == 1700000000 + assert event.data['req_id'] == 'callback-1' + assert event.data['event'] == body['event'] + assert type(event).model_validate(event.model_dump()).type == event.type + if kind == 'template_card_event': + assert event.event_key == 'approve' + assert event.selected_items[0]['option_ids'] == ['yes'] + + +@pytest.mark.asyncio +async def test_managed_card_callback_does_not_start_business_event(): + adapter = make_adapter() + listener = AsyncMock() + adapter.listeners[platform_events.EBAEvent] = listener + await adapter._handle_interaction_event( + WecomBotEvent( + { + 'type': 'single', + 'msgtype': 'event', + 'userid': 'user-1', + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': 'lbi-managed', + 'event_key': 'lbi:token:a:0', + }, + }, + } + ) + ) + listener.assert_awaited_once() + event = listener.await_args.args[0] + assert event.type == 'platform.specific' + assert event.action == 'interaction.submitted' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('webhook', [False, True]) +async def test_enter_chat_reply_uses_welcome_callback_and_checks_deadline(webhook): + import time + + adapter = make_adapter(webhook) + adapter.bot.reply_welcome = AsyncMock(return_value={'ok': True}) + context = { + 'event_type': 'wecombot.enter_chat', + 'data': { + 'timestamp': time.time(), + 'message_id': 'entry-1', + 'data': {'req_id': 'request-1'}, + }, + } + message = platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message) + adapter.bot.reply_welcome.assert_awaited_once_with('entry-1' if webhook else 'request-1', 'Welcome') + context['data']['timestamp'] -= 10 + with pytest.raises(ValueError, match='5 seconds'): + await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message) + + +@pytest.mark.asyncio +async def test_file_attachment_is_uploaded_and_delivered_after_stream(): + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + message = platform_message.MessageChain( + [ + platform_message.Plain(text='Here is your file'), + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ) + await adapter.reply_message_chunk(source, None, message, is_final=True) + adapter.bot.upload_media.assert_awaited_once_with(b'hello', 'report.txt', media_type='file') + adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['upload', 'reply', 'timeout']) +async def test_file_delivery_errors_are_not_silent(failure): + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value=None if failure == 'upload' else {'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value=None if failure == 'timeout' else {'errcode': 40001}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + with pytest.raises(RuntimeError, match='WeCom file'): + await adapter.reply_message( + source, + platform_message.MessageChain( + [ + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ), + ) + + +@pytest.mark.asyncio +async def test_multi_round_file_completion_finalizes_once_without_text_fallback(): + from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + prefix = 'planCreating your file.' + final = 'doneYour file is ready.' + completed = Message(role='assistant', content=final) + completed.attachments = platform_message.MessageChain( + [ + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ) + + async def results(): + yield MessageChunk(role='assistant', content=prefix) + yield MessageChunk(role='assistant', content=prefix + final, tool_calls=[], is_final=True) + yield completed + + async for result in coalesce_stream_results(results()): + await adapter.reply_message_chunk( + source, + None, + result.get_content_platform_message_chain(), + is_final=isinstance(result, Message) or result.is_final, + ) + assert sum(call.kwargs['is_final'] for call in adapter.bot.push_stream_chunk.await_args_list) == 1 + adapter.bot.reply_text.assert_not_awaited() + adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1') + final_text = adapter.bot.push_stream_chunk.await_args_list[-1].args[1] + assert final_text.count('') == 1 + assert final_text.count('Your file is ready.') == 1 diff --git a/tests/unit_tests/platform/test_wecombot_stream_empty_final.py b/tests/unit_tests/platform/test_wecombot_stream_empty_final.py index e21d163b6..8f18fa866 100644 --- a/tests/unit_tests/platform/test_wecombot_stream_empty_final.py +++ b/tests/unit_tests/platform/test_wecombot_stream_empty_final.py @@ -117,3 +117,61 @@ async def test_real_final_chunk_is_sent_and_closes_stream(): assert frames[-1]['stream']['content'] == 'the answer' assert frames[-1]['stream']['finish'] is True assert not client.stream_still_open() + + +@pytest.mark.asyncio +async def test_duplicate_final_is_acknowledged_without_sending_again(): + client = RecordingClient() + client.seed_stream() + assert await client.push_stream_chunk('msg-1', 'answer', is_final=True) + assert await client.push_stream_chunk('msg-1', 'answer', is_final=True) + assert len(client.stream_frames()) == 1 + assert not await client.push_stream_chunk('different-message', 'answer', is_final=True) + assert not await client.push_stream_chunk('msg-1', 'different answer', is_final=True) + + +@pytest.mark.asyncio +async def test_runner_cumulative_snapshots_reach_wire_once_without_concatenation(): + from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + from tests.unit_tests.platform.test_wecombot_eba_adapter import ( + make_adapter, + wecombot_event, + WecomBotEventConverter, + ) + + client = RecordingClient() + client.seed_stream() + adapter = make_adapter() + adapter.bot = client + event = await WecomBotEventConverter().target2yiri(wecombot_event()) + snapshots = ['test', 'test\nOK', 'test\nOK!'] + + async def runner(): + # LocalAgent's StreamingModelCaller sets content to the accumulated + # text, leaves all_content unset, then emits message.completed. + for index, text in enumerate(snapshots): + yield MessageChunk(role='assistant', content=text, msg_sequence=index + 1, is_final=index == 2) + yield Message(role='assistant', content=snapshots[-1]) + + async for result in coalesce_stream_results(runner()): + final = isinstance(result, Message) or result.is_final + if isinstance(result, MessageChunk): + result = result.model_copy(update={'content': result.all_content}) + await adapter.reply_message_chunk(event, result, result.get_content_platform_message_chain(), is_final=final) + + frames = client.stream_frames() + from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text + + assert [frame['stream']['content'] for frame in frames] == [format_stream_text(text) for text in snapshots] + assert [frame['stream']['finish'] for frame in frames] == [False, False, True] + assert len(client.replies) == 3 # No extra plain-text reply after completion. + + +@pytest.mark.asyncio +async def test_revised_snapshot_replaces_previous_text(): + client = RecordingClient() + client.seed_stream() + await client.push_stream_chunk('msg-1', 'test\nanswer') + await client.push_stream_chunk('msg-1', 'answer', is_final=True) + assert client.stream_frames()[-1]['stream']['content'] == 'answer' diff --git a/tests/unit_tests/platform/test_wecombot_stream_text.py b/tests/unit_tests/platform/test_wecombot_stream_text.py new file mode 100644 index 000000000..fe19d7bab --- /dev/null +++ b/tests/unit_tests/platform/test_wecombot_stream_text.py @@ -0,0 +1,30 @@ +import pytest + +from langbot.pkg.platform.adapters.wecombot.stream_text import format_stream_text + + +def test_multiple_rounds_have_one_thinking_section_and_separated_answers(): + raw = 'oneChecking.twoMore checks.threeDone.' + result = format_stream_text(raw) + assert result == '\none\n\ntwo\n\nthree\n\n\nChecking.\n\nMore checks.\n\nDone.' + assert format_stream_text(result) == result + + +@pytest.mark.parametrize('suffix', ['<', 'oneChecking.' + suffix) + assert result.endswith('Checking.') + + +def test_later_incomplete_thinking_remains_inside_single_section(): + result = format_stream_text('oneChecking.two') + assert result == '\none\n\ntwo\n\n\nChecking.' + + +def test_plain_markdown_is_unchanged(): + text = ' hello\n\n```python\na < b\n```\n' + assert format_stream_text(text) == text + + +def test_initial_partial_thinking_tag_waits_for_next_snapshot(): + assert format_stream_text('test你好', is_final=False) assert await client.push_stream_chunk('msg-1', '你好', is_final=True) assert sent == [ ('req-1', 'stream-1', '你', False), ('req-1', 'stream-1', '你好', False), + ('req-1', 'stream-1', 'test你好', False), ('req-1', 'stream-1', '你好', True), ] @@ -385,6 +387,7 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups(): assert await client.push_stream_chunk('msg-1', '你', is_final=False) assert await client.push_stream_chunk('msg-1', '你好', is_final=False) + assert await client.push_stream_chunk('msg-1', 'test你好', is_final=False) assert await client.push_stream_chunk('msg-1', '你好', is_final=True) assert session.queue.qsize() == 1 @@ -566,3 +569,132 @@ def test_action_stage_only_shows_content_after_fields_without_placeholders(): assert card['sub_title_text'] == '请选择操作' assert '{{#$output.' not in card['sub_title_text'] assert [button['text'] for button in card['button_list']] == ['yes', 'no'] + + +@pytest.mark.asyncio +async def test_webhook_enter_chat_returns_welcome_without_message_stream(): + from unittest.mock import AsyncMock + from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply) + ordinary = AsyncMock() + client.on_message('single')(ordinary) + + async def welcome(event): + assert isinstance(event, WecomBotEvent) + await client.reply_welcome(event.message_id, 'Welcome') + + client.on_message('event')(welcome) + result = await client._handle_enter_chat( + { + 'msgid': 'entry-1', + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor'}, + 'event': {'eventtype': 'enter_chat'}, + }, + 'nonce', + ) + assert result == {'msgtype': 'text', 'text': {'content': 'Welcome'}} + ordinary.assert_not_awaited() + assert not client._welcome_replies + with pytest.raises(ValueError, match='expired'): + await client.reply_welcome('entry-1', 'too late') + await client.close() + + +@pytest.mark.asyncio +async def test_webhook_unmanaged_card_dispatches_but_expired_dify_does_not(): + import asyncio + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(return_value={}) + handler = AsyncMock() + client.on_message('template_card_event')(handler) + for task_id in ['business-card', 'dify-expired']: + await client._handle_template_card_event( + { + 'msgid': task_id, + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor'}, + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': task_id, + 'event_key': 'approve', + }, + }, + }, + 'nonce', + ) + await asyncio.gather(*client._dispatch_tasks) + handler.assert_awaited_once() + assert handler.await_args.args[0].userid == 'visitor' + await client.close() + + +@pytest.mark.asyncio +async def test_ws_welcome_uses_dedicated_command(): + from unittest.mock import AsyncMock + + client = WecomBotWsClient('bot-id', 'secret', object()) + client._send_reply = AsyncMock(return_value={}) + await client.reply_welcome('req-1', 'Welcome') + client._send_reply.assert_awaited_once_with( + 'req-1', {'msgtype': 'text', 'text': {'content': 'Welcome'}}, cmd='aibot_respond_welcome_msg' + ) + + +@pytest.mark.asyncio +async def test_expired_custom_managed_card_never_becomes_business_event(): + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotWsClient('bot-id', 'secret', logger) + handler = AsyncMock() + client.on_message('template_card_event')(handler) + client._drop_pending_form_task('custom-managed-task', {}) + body = { + 'msgtype': 'event', + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': 'custom-managed-task', + 'event_key': 'approve', + }, + }, + } + await client._handle_template_card_event_frame({'headers': {'req_id': 'r1'}}, body) + handler.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_webhook_welcome_timeout_cleans_pending_reply(monkeypatch): + import asyncio + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply) + + async def timeout(awaitable, timeout): + awaitable.cancel() + raise asyncio.TimeoutError + + monkeypatch.setattr(asyncio, 'wait_for', timeout) + result = await client._handle_enter_chat( + { + 'msgid': 'no-reply', + 'msgtype': 'event', + 'event': {'eventtype': 'enter_chat'}, + }, + 'nonce', + ) + assert result == {} + assert not client._welcome_replies + await client.close() diff --git a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py index e14a32745..e30b44a1c 100644 --- a/tests/unit_tests/platform/test_wecomcs_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecomcs_eba_adapter.py @@ -200,7 +200,7 @@ async def test_wecomcs_event_converter_maps_media_and_unknown_messages(): assert isinstance(file_event.message_chain[1], platform_message.File) assert isinstance(voice_event.message_chain[1], platform_message.Voice) assert isinstance(unknown_event, platform_events.PlatformSpecificEvent) - assert unknown_event.action == 'wecomcs.event' + assert unknown_event.action == 'wecomcs.unknown' @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/conftest.py b/tests/unit_tests/plugin/conftest.py new file mode 100644 index 000000000..3285bb2a0 --- /dev/null +++ b/tests/unit_tests/plugin/conftest.py @@ -0,0 +1,38 @@ +"""Database fixture for plugin persistence behavior tests.""" + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.bstorage import BinaryStorage +from langbot.pkg.entity.persistence.plugin import PluginSetting +from langbot.pkg.entity.persistence.user import User +from langbot.pkg.entity.persistence.workspace import Workspace + + +@pytest.fixture +async def plugin_db(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.execute(text('PRAGMA foreign_keys=ON')) + await connection.run_sync( + Base.metadata.create_all, + tables=[User.__table__, Workspace.__table__, PluginSetting.__table__, BinaryStorage.__table__], + ) + async with AsyncSession(engine, expire_on_commit=False) as session: + session.add_all( + Workspace( + uuid=workspace, + instance_uuid='instance-a', + name=workspace, + slug=workspace, + source='cloud_projection', + ) + for workspace in ('workspace-a', 'workspace-b') + ) + await session.flush() + yield session + finally: + await engine.dispose() diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py index 337ec16a1..5f671fc8d 100644 --- a/tests/unit_tests/plugin/test_connector_pure.py +++ b/tests/unit_tests/plugin/test_connector_pure.py @@ -123,6 +123,7 @@ class TestExtractDepsMetadata: # Should find requirements.txt in subdirectory assert task_context.metadata['deps_total'] == 2 + assert task_context.metadata['deps_list'] == ['pytest', 'black'] def test_archive_preview_rejects_extreme_compression_ratio(self): from langbot.pkg.plugin.connector import inspect_plugin_archive_metadata @@ -147,13 +148,6 @@ class TestParsePluginId: assert author == 'myauthor' assert name == 'myplugin' - def test_parse_plugin_id_empty(self): - """Empty plugin ID is invalid.""" - from langbot.pkg.plugin.connector import PluginRuntimeConnector - - with pytest.raises(ValueError): - PluginRuntimeConnector._parse_plugin_id('') - @pytest.mark.asyncio async def test_marketplace_response_reader_is_bounded(): diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index 0b54f9384..bfb7c2431 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -583,11 +583,15 @@ async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply @pytest.mark.asyncio -@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)]) +@pytest.mark.parametrize('remaining_references', [1, 0]) async def test_artifact_cleanup_is_reference_counted_within_workspace( remaining_references: int, - statement_count: int, + plugin_db, ): + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + from langbot.pkg.entity.persistence.bstorage import BinaryStorage + app = SimpleNamespace( instance_config=SimpleNamespace(data={'plugin': {'enable': True}}), deployment=SimpleNamespace(mode='cloud'), @@ -598,19 +602,38 @@ async def test_artifact_cleanup_is_reference_counted_within_workspace( workspace_uuid='workspace-a', placement_generation=1, ) - statements = [] - - async def execute(statement): - statements.append(statement) - return SimpleNamespace(scalar_one=lambda: remaining_references) + digest = 'a' * 64 + settings = [('workspace-b', digest), ('workspace-a', 'b' * 64)] + if remaining_references: + settings.append(('workspace-a', digest)) + plugin_db.add_all( + PluginSetting(workspace_uuid=workspace, plugin_author='author', plugin_name=artifact, artifact_digest=artifact) + for workspace, artifact in settings + ) + plugin_db.add_all( + BinaryStorage( + workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'pkg' + ) + for workspace, key, owner_type, owner in [ + ('workspace-a', 'target', 'plugin_artifact', digest), + ('workspace-b', 'other-workspace', 'plugin_artifact', digest), + ('workspace-a', 'other-artifact', 'plugin_artifact', 'b' * 64), + ('workspace-a', 'other-owner-type', 'plugin', digest), + ] + ) + await plugin_db.flush() await connector._delete_artifact_if_unreferenced( execution_context, - 'a' * 64, - execute=execute, + digest, + execute=plugin_db.execute, ) - assert len(statements) == statement_count + remaining = (await plugin_db.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all() + expected = {'other-workspace', 'other-artifact', 'other-owner-type'} + if remaining_references: + expected.add('target') + assert set(remaining) == expected @pytest.mark.asyncio diff --git a/tests/unit_tests/plugin/test_extract_deps.py b/tests/unit_tests/plugin/test_extract_deps.py index 0980f4cc3..0753e6b3e 100644 --- a/tests/unit_tests/plugin/test_extract_deps.py +++ b/tests/unit_tests/plugin/test_extract_deps.py @@ -113,6 +113,7 @@ numpy""" # Should find nested requirements.txt (ends with 'requirements.txt') assert task_context.metadata.get('deps_total') == 2 + assert task_context.metadata['deps_list'] == ['requests', 'flask'] def test_no_requirements_in_zip(self): """Test handling ZIP without requirements.txt.""" diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index 23be3490b..98dfeba04 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -5,6 +5,7 @@ Tests handler helper methods that don't require full handler setup. from __future__ import annotations +from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, Mock import pytest @@ -18,6 +19,38 @@ from langbot_plugin.entities.io.context import ( ) +@pytest.mark.asyncio +async def test_plugin_reset_clears_runner_history_in_the_authorized_scope(monkeypatch): + reset = AsyncMock() + monkeypatch.setattr('langbot.pkg.agent.runner.context_reset.reset_runner_context', reset) + transactions = [] + + @asynccontextmanager + async def tenant_uow(workspace): + transactions.append(workspace) + yield + + query = SimpleNamespace( + bot_uuid='bot-a', + launcher_type=SimpleNamespace(value='group'), + launcher_id='guild-a', + sender_id='user-a', + variables={}, + session=SimpleNamespace(using_conversation=SimpleNamespace(uuid=None)), + ) + app = SimpleNamespace( + query_pool=SimpleNamespace(cached_queries={'query-a': query}), + persistence_mgr=SimpleNamespace(tenant_uow=tenant_uow), + logger=SimpleNamespace(debug=Mock(), warning=Mock()), + ) + h = make_handler(app) + response = await h.actions[PluginToRuntimeAction.CREATE_NEW_CONVERSATION.value]({'query_id': 'query-a'}) + assert response.code == 0 + reset.assert_awaited_once_with(app.persistence_mgr, 'workspace-a', 'bot-a', 'group_guild-a') + assert transactions == ['workspace-a'] + assert query.session.using_conversation is None + + def make_handler(app): """Create a RuntimeConnectionHandler with mocked external connection.""" from langbot.pkg.plugin.handler import RuntimeConnectionHandler diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index 105cd6125..8fd068745 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -236,10 +236,16 @@ class TestInitializePluginSettings: app.persistence_mgr.execute_async.assert_awaited_once() @pytest.mark.asyncio - async def test_existing_desired_setting_remains_core_owned(self, app): + async def test_existing_desired_setting_remains_core_owned(self, app, plugin_db): """Runtime initialization validates identity without rewriting Core state.""" + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + runtime_handler = make_handler(app) - existing_setting = SimpleNamespace( + existing_setting = PluginSetting( + workspace_uuid='workspace-a', + plugin_author='test-author', + plugin_name='test-plugin', enabled=False, priority=5, config={'key': 'value'}, @@ -247,7 +253,29 @@ class TestInitializePluginSettings: runtime_revision=1, artifact_digest='a' * 64, ) - app.persistence_mgr.execute_async.return_value = make_result(existing_setting) + other_setting = PluginSetting( + workspace_uuid='workspace-b', + plugin_author='test-author', + plugin_name='test-plugin', + enabled=True, + ) + plugin_db.add_all([other_setting, existing_setting]) + await plugin_db.flush() + + async def execute(statement): + result = await plugin_db.execute(statement) + return result.scalars() if statement.is_select or statement.is_update else result + + app.persistence_mgr.execute_async.side_effect = execute + before = ( + ( + await plugin_db.execute( + sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid) + ) + ) + .mappings() + .all() + ) response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value]( { @@ -260,6 +288,17 @@ class TestInitializePluginSettings: assert response.code == 0 app.persistence_mgr.execute_async.assert_awaited_once() + plugin_db.expire_all() + after = ( + ( + await plugin_db.execute( + sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid) + ) + ) + .mappings() + .all() + ) + assert after == before class TestSetBinaryStorage: @@ -1630,7 +1669,7 @@ class TestAgentRunProxyActions: app.box_service.execute_tool.assert_awaited_once_with( {'command': 'pwd'}, query, - skill_name=None, + read_only_mounts=[], ) @pytest.mark.asyncio @@ -1867,3 +1906,185 @@ async def test_embedding_api_rejects_model_outside_runner_grants(): assert response.code != 0 assert 'not authorized' in response.message app.model_mgr.get_embedding_model_by_uuid.assert_not_awaited() + + +@pytest.fixture +def knowledge_host(monkeypatch): + from langbot.pkg.agent.runner import session_registry + from langbot.pkg.entity.persistence.rag import KnowledgeBase + from langbot.pkg.rag.knowledge.kbmgr import RAGManager, RuntimeKnowledgeBase + + registry = session_registry.AgentRunSessionRegistry() + monkeypatch.setattr(session_registry, '_global_registry', registry) + app = SimpleNamespace( + logger=Mock(), + persistence_mgr=SimpleNamespace(), + plugin_connector=SimpleNamespace( + require_workspace_context=AsyncMock(side_effect=lambda context: context), + call_rag_retrieve=AsyncMock( + return_value={ + 'results': [ + { + 'id': 'document-a', + 'content': [{'type': 'text', 'text': 'selected document'}], + 'metadata': {}, + 'distance': 0.1, + } + ] + } + ), + ), + ) + runtime = make_handler(app) + app.rag_mgr = RAGManager(app) + for kb_id, workspace_uuid in [('kb-a', 'workspace-a'), ('kb-unselected', 'workspace-a'), ('kb-b', 'workspace-b')]: + context = ExecutionContext(instance_uuid='instance-a', workspace_uuid=workspace_uuid, placement_generation=1) + entity = KnowledgeBase( + uuid=kb_id, + workspace_uuid=workspace_uuid, + name=kb_id, + knowledge_engine_plugin_id='author/engine', + collection_id=kb_id, + retrieval_settings={}, + ) + app.rag_mgr._cache_runtime(RuntimeKnowledgeBase(app, entity, context)) + app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(wraps=app.rag_mgr.get_knowledge_base_by_uuid) + return SimpleNamespace(app=app, runtime=runtime, registry=registry) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'action', [PluginToRuntimeAction.RETRIEVE_KNOWLEDGE, PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE] +) +@pytest.mark.parametrize('denial', [None, 'unselected', 'operation', 'expired', 'plugin']) +async def test_knowledge_actions_enforce_run_grants_before_resource_access(knowledge_host, action, denial): + host = knowledge_host + await host.registry.register( + run_id='run-a', + runner_id='plugin:test-author/test-plugin/runner', + query_id=None, + plugin_identity='other/plugin' if denial == 'plugin' else 'test-author/test-plugin', + workspace_id='workspace-a', + resources=make_agent_resources( + knowledge_bases=[{'kb_id': 'kb-a', 'operations': ['list'] if denial == 'operation' else ['retrieve']}] + ), + ) + response = await host.runtime.actions[action.value]( + { + 'run_id': 'expired' if denial == 'expired' else 'run-a', + 'kb_id': 'kb-unselected' if denial == 'unselected' else 'kb-a', + 'query_text': 'hello', + } + ) + if denial is None: + assert response.code == 0 + assert [item['id'] for item in response.data['results']] == ['document-a'] + host.app.plugin_connector.call_rag_retrieve.assert_awaited_once() + else: + assert response.code != 0 + assert ( + response.message + == { + 'unselected': 'knowledge_base kb-unselected is not authorized for operation retrieve for this agent run', + 'operation': 'knowledge_base kb-a is not authorized for operation retrieve for this agent run', + 'expired': 'Run session expired not found or expired', + 'plugin': 'Plugin identity mismatch: caller test-author/test-plugin is not authorized for run_id run-a', + }[denial] + ) + assert not response.data + host.app.rag_mgr.get_knowledge_base_by_uuid.assert_not_awaited() + host.app.plugin_connector.call_rag_retrieve.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'action', [PluginToRuntimeAction.RETRIEVE_KNOWLEDGE, PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE] +) +async def test_knowledge_actions_keep_foreign_runs_in_trusted_workspace(knowledge_host, action): + host = knowledge_host + await host.registry.register( + run_id='run-b', + runner_id='plugin:test-author/test-plugin/runner', + query_id=None, + plugin_identity='test-author/test-plugin', + workspace_id='workspace-b', + resources=make_agent_resources(knowledge_bases=[{'kb_id': 'kb-b'}]), + ) + response = await host.runtime.actions[action.value]( + {'run_id': 'run-b', 'kb_id': 'kb-b', 'query_text': 'hello', 'workspace_uuid': 'workspace-b'} + ) + assert response.code != 0 + assert response.message == 'Knowledge base kb-b not found' + assert not response.data + host.app.rag_mgr.get_knowledge_base_by_uuid.assert_awaited_once_with(TEST_EXECUTION_CONTEXT, 'kb-b') + host.app.plugin_connector.call_rag_retrieve.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('selected', [True, False]) +@pytest.mark.parametrize('scope_source', ['preprocessed', 'custom_runner']) +async def test_pipeline_knowledge_action_enforces_query_scope(knowledge_host, selected, scope_source): + from langbot.pkg.agent.runner.descriptor import RunnerDescriptor + + host = knowledge_host + runner_id = 'plugin:custom/runner/default' + query_context = ExecutionContext( + instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=1, bot_uuid='bot-a' + ) + query = SimpleNamespace( + _execution_context=query_context, + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + variables={'_knowledge_base_uuids': ['kb-a']} if scope_source == 'preprocessed' else {}, + pipeline_config={ + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': { + runner_id: {'custom-kb-selector': ['kb-unselected' if scope_source == 'preprocessed' else 'kb-a']} + }, + } + }, + session=SimpleNamespace(launcher_type=SimpleNamespace(value='person'), launcher_id='conversation-a'), + bot_uuid='bot-a', + sender_id='user-a', + ) + host.app.runner_registry = SimpleNamespace( + get=AsyncMock( + return_value=RunnerDescriptor( + usages=['agent'], + id=runner_id, + source='plugin', + label={'en_US': 'Custom Runner'}, + plugin_author='custom', + plugin_name='runner', + runner_name='default', + config_schema=[{'name': 'custom-kb-selector', 'type': 'knowledge-base-multi-selector', 'default': []}], + capabilities={'knowledge_retrieval': True}, + permissions={'knowledge_bases': ['list', 'retrieve']}, + ) + ) + ) + host.app.query_pool = SimpleNamespace(get_query=AsyncMock(return_value=query)) + response = await host.runtime.actions[PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE.value]( + { + 'query_uuid': 'query-a', + 'query_id': 1, + 'kb_id': 'kb-a' if selected else 'kb-unselected', + 'query_text': 'hello', + } + ) + host.app.query_pool.get_query.assert_awaited_once_with('workspace-a', 'query-a') + if scope_source == 'custom_runner': + host.app.runner_registry.get.assert_awaited_once_with(query_context, runner_id, None) + else: + host.app.runner_registry.get.assert_not_awaited() + if selected: + assert response.code == 0 + assert [item['id'] for item in response.data['results']] == ['document-a'] + host.app.plugin_connector.call_rag_retrieve.assert_awaited_once() + else: + assert response.code != 0 + assert 'not configured for this pipeline' in response.message + host.app.rag_mgr.get_knowledge_base_by_uuid.assert_not_awaited() + host.app.plugin_connector.call_rag_retrieve.assert_not_awaited() diff --git a/tests/unit_tests/plugin/test_handler_helpers.py b/tests/unit_tests/plugin/test_handler_helpers.py index dc86b7a5f..145d6e5bd 100644 --- a/tests/unit_tests/plugin/test_handler_helpers.py +++ b/tests/unit_tests/plugin/test_handler_helpers.py @@ -10,6 +10,27 @@ from __future__ import annotations import pytest from unittest.mock import Mock, AsyncMock from importlib import import_module +from types import SimpleNamespace + + +@pytest.mark.parametrize( + 'call_args', + [ + {'num_retries': 0}, + {'max_retries': 0}, + {'timeout': 3}, + {'request_policy': {'stream_idle_timeout': 2}}, + ], +) +def test_llm_bridge_keeps_model_and_call_policy_layers_separate(call_args): + from langbot.pkg.provider.modelmgr.request_policy import RequestPolicy + + model_args = {'temperature': 0.2, 'request_policy': {'retries': 2, 'read_timeout': 90}} + model = SimpleNamespace(model_entity=SimpleNamespace(extra_args=model_args)) + merged = get_handler_module()._merge_model_extra_args(model, call_args, llm_policy=True) + assert RequestPolicy.resolve(model_args, merged) == RequestPolicy.resolve(model_args, call_args) + assert merged['temperature'] == 0.2 + assert model_args['request_policy'] == {'retries': 2, 'read_timeout': 90} def get_handler_module(): @@ -86,41 +107,69 @@ class TestMakeRagErrorResponse: class TestCleanupPluginData: """Tests for cleanup_plugin_data method.""" - @pytest.mark.asyncio - async def test_deletes_plugin_settings(self): - """Test that plugin settings are deleted.""" - handler_module = get_handler_module() + @pytest.fixture + async def cleanup(self, plugin_db): + from langbot.pkg.entity.persistence.plugin import PluginSetting + from langbot.pkg.entity.persistence.bstorage import BinaryStorage + from langbot_plugin.entities.io.context import InstallationBinding - mock_app = Mock() - mock_app.persistence_mgr = AsyncMock() - mock_app.persistence_mgr.execute_async = AsyncMock() - - # Mock the handler without connection (we only need ap) - handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler) - handler_instance.ap = mock_app - - # Call cleanup_plugin_data - await handler_module.RuntimeConnectionHandler.cleanup_plugin_data( - handler_instance, 'test-author', 'test-plugin' + plugin_db.add_all( + PluginSetting(workspace_uuid=workspace, plugin_author=author, plugin_name=name) + for workspace, author, name in [ + ('workspace-a', 'test-author', 'test-plugin'), + ('workspace-b', 'test-author', 'test-plugin'), + ('workspace-a', 'other-author', 'test-plugin'), + ('workspace-a', 'test-author', 'other-plugin'), + ] ) - - # Verify plugin settings delete was called - calls = mock_app.persistence_mgr.execute_async.call_args_list - assert len(calls) >= 1 + plugin_db.add_all( + BinaryStorage( + workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'data' + ) + for workspace, key, owner_type, owner in [ + ('workspace-a', 'target', 'plugin', 'test-author/test-plugin'), + ('workspace-b', 'other-workspace', 'plugin', 'test-author/test-plugin'), + ('workspace-a', 'other-owner', 'plugin', 'other-author/test-plugin'), + ('workspace-a', 'other-type', 'skill', 'test-author/test-plugin'), + ] + ) + await plugin_db.flush() + app = Mock() + app.persistence_mgr.execute_async = plugin_db.execute + handler = get_handler_module().RuntimeConnectionHandler(Mock(), AsyncMock(), app) + binding = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=1, + artifact_digest='a' * 64, + ) + with handler.installation_scope(binding): + await handler.cleanup_plugin_data('test-author', 'test-plugin') + return plugin_db @pytest.mark.asyncio - async def test_deletes_binary_storage(self): + async def test_deletes_plugin_settings(self, cleanup): + import sqlalchemy + from langbot.pkg.entity.persistence.plugin import PluginSetting + + rows = ( + await cleanup.execute( + sqlalchemy.select(PluginSetting.workspace_uuid, PluginSetting.plugin_author, PluginSetting.plugin_name) + ) + ).all() + assert set(rows) == { + ('workspace-b', 'test-author', 'test-plugin'), + ('workspace-a', 'other-author', 'test-plugin'), + ('workspace-a', 'test-author', 'other-plugin'), + } + + @pytest.mark.asyncio + async def test_deletes_binary_storage(self, cleanup): """Test that binary storage is deleted.""" - handler_module = get_handler_module() + import sqlalchemy + from langbot.pkg.entity.persistence.bstorage import BinaryStorage - mock_app = Mock() - mock_app.persistence_mgr = AsyncMock() - mock_app.persistence_mgr.execute_async = AsyncMock() - - handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler) - handler_instance.ap = mock_app - - 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 + keys = (await cleanup.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all() + assert set(keys) == {'other-workspace', 'other-owner', 'other-type'} diff --git a/tests/unit_tests/plugin/test_plugin_id_parsing.py b/tests/unit_tests/plugin/test_plugin_id_parsing.py index f76463785..810eb85f9 100644 --- a/tests/unit_tests/plugin/test_plugin_id_parsing.py +++ b/tests/unit_tests/plugin/test_plugin_id_parsing.py @@ -5,10 +5,6 @@ import pytest from langbot.pkg.plugin.connector import PluginRuntimeConnector -def test_parse_plugin_id_accepts_author_name(): - assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine') - - @pytest.mark.parametrize( 'plugin_id', [ diff --git a/tests/unit_tests/plugin/test_worker_mode_quotas.py b/tests/unit_tests/plugin/test_worker_mode_quotas.py new file mode 100644 index 000000000..6a11be777 --- /dev/null +++ b/tests/unit_tests/plugin/test_worker_mode_quotas.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.core.stages.load_config import _complete_runtime_policy_defaults, _apply_env_overrides_to_config +from langbot.pkg.plugin.connector import PluginRuntimeConnector + + +@pytest.mark.parametrize('mode', ['cloud', 'oss']) +def test_mode_overrides_only_reach_cloud_runtime(mode, monkeypatch): + monkeypatch.setenv('PLUGIN__WORKER__DEDICATED__MAX_CPUS', '0.5') + monkeypatch.setenv('PLUGIN__WORKER__DEDICATED__MAX_MEMORY_MB', '256') + monkeypatch.setenv('PLUGIN__WORKER__SHARED__MAX_CPUS', '1.0') + monkeypatch.setenv('PLUGIN__WORKER__SHARED__MAX_MEMORY_MB', '512') + config = _apply_env_overrides_to_config(_complete_runtime_policy_defaults({'plugin': {'enable': True}})) + app = SimpleNamespace(instance_config=SimpleNamespace(data=config), deployment=SimpleNamespace(mode=mode)) + connector = PluginRuntimeConnector(app, AsyncMock()) + policy = connector._load_worker_policy() + if mode == 'cloud': + assert policy.dedicated.max_cpus == 0.5 + assert policy.dedicated.max_memory_mb == 256 + assert policy.shared.max_cpus == 1.0 + assert policy.shared.max_memory_mb == 512 + else: + assert policy.dedicated is None + assert policy.shared is None + assert policy.max_cpus == 1.0 + assert policy.max_memory_mb == 512 + + +def test_cloud_unset_overrides_inherit_existing_limits(): + config = _complete_runtime_policy_defaults( + {'plugin': {'enable': True, 'worker': {'max_cpus': 1.5, 'max_memory_mb': 768}}} + ) + app = SimpleNamespace(instance_config=SimpleNamespace(data=config), deployment=SimpleNamespace(mode='cloud')) + policy = PluginRuntimeConnector(app, AsyncMock())._load_worker_policy() + for mode in ('dedicated', 'shared-runtime-v1'): + resolved = policy.for_execution_mode(mode, 'shared') + assert resolved.max_cpus == 1.5 + assert resolved.max_memory_mb == 768 diff --git a/tests/unit_tests/provider/test_codex.py b/tests/unit_tests/provider/test_codex.py index 14dbd6832..cb0e9e046 100644 --- a/tests/unit_tests/provider/test_codex.py +++ b/tests/unit_tests/provider/test_codex.py @@ -1,6 +1,7 @@ """Replay synthetic HTTP/SSE traffic through the real Codex requester.""" import json +import asyncio from collections import OrderedDict from types import SimpleNamespace from unittest.mock import AsyncMock @@ -16,6 +17,32 @@ TOKENS = {'access_token': 'access-secret', 'account_id': 'account', 'connection_ MODEL = SimpleNamespace(model_entity=SimpleNamespace(name='codex-test', extra_args={}, reasoning_config=None)) +@pytest.mark.asyncio +async def test_response_headers_obey_host_first_event_deadline(monkeypatch): + from langbot.pkg.provider.modelmgr.errors import RequesterError + from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call + + cancelled = asyncio.Event() + + async def handler(request): + try: + await asyncio.sleep(10) + finally: + cancelled.set() + + obj = requester(monkeypatch, handler) + state = CallState(RequestPolicy(first_event_timeout=0.02)) + token = current_call.set(state) + try: + with pytest.raises(RequesterError) as caught: + await obj.invoke_llm(None, MODEL, []) + finally: + current_call.reset(token) + assert caught.value.code == 'timeout' + assert caught.value.phase == 'first_event' + assert cancelled.is_set() + + def requester(monkeypatch, handler): real_client = httpx.AsyncClient monkeypatch.setattr( @@ -198,3 +225,51 @@ def test_advanced_parameters_cannot_override_transport(monkeypatch, key): obj = requester(monkeypatch, lambda request: httpx.Response(200)) with pytest.raises(ValueError, match='Unsupported Codex advanced'): obj._body(None, MODEL, [], None, {key: 'secret'}, TOKENS) + + +@pytest.mark.asyncio +async def test_early_close_releases_nested_http_stream(monkeypatch): + closed = asyncio.Event() + + class Body(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"type":"response.output_text.delta","delta":"first"}\n\n' + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + obj = requester(monkeypatch, lambda request: httpx.Response(200, stream=Body())) + stream = obj.invoke_llm_stream(None, MODEL, []) + assert (await anext(stream)).content == 'first' + await stream.aclose() + assert closed.is_set() + + +@pytest.mark.asyncio +async def test_host_deadline_does_not_cancel_suspended_consumer(monkeypatch, runtime_provider, runtime_llm_model): + from langbot.pkg.provider.modelmgr.errors import RequesterError + from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT + + runtime_provider.requester = requester( + monkeypatch, + lambda request: stream( + [ + {'type': 'response.output_text.delta', 'delta': 'first'}, + {'type': 'response.completed', 'response': {'status': 'completed', 'output': []}}, + ] + ), + ) + runtime_llm_model.model_entity.extra_args = {} + result = runtime_provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'total_timeout': 0.05}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + assert (await anext(result)).content == 'first' + await asyncio.sleep(0.07) + with pytest.raises(RequesterError) as caught: + await anext(result) + assert caught.value.phase == 'total' diff --git a/tests/unit_tests/provider/test_http_pool_streams.py b/tests/unit_tests/provider/test_http_pool_streams.py new file mode 100644 index 000000000..b474e19d5 --- /dev/null +++ b/tests/unit_tests/provider/test_http_pool_streams.py @@ -0,0 +1,285 @@ +import asyncio +import json +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest +from aiohttp import web + +from langbot.pkg.provider.modelmgr.http_pool import _CompletedSSEStream, RequesterHTTPPool +from langbot.pkg.provider.modelmgr.errors import RequesterError +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester +from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call +from tests.unit_tests.provider.test_reasoning_control import _runtime_model +from scripts.llm_latency_probe import SSEMeter, cache_tokens + + +def test_request_timing_uses_high_resolution_monotonic_clock(monkeypatch): + from langbot.pkg.provider.modelmgr import request_policy + + ticks = iter([100.0, 100.0004]) + monkeypatch.setattr(request_policy.time, 'perf_counter', lambda: next(ticks)) + state = CallState(RequestPolicy()) + state.mark('request_prepared_ms') + assert state.metrics['request_prepared_ms'] == 0.4 + + +def test_meter_accepts_split_utf8_and_usage_without_content(): + meter = SSEMeter() + event = {'choices': [{'delta': {'content': '你好'}}]} + data = ('data: ' + json.dumps(event, ensure_ascii=False) + '\r\n\r\n').encode() + for byte in data: + meter.feed(bytes([byte]), 1) + meter.feed(b'data: {"choices":[],"usage":{"prompt_tokens_details":{"cached_tokens":12}}}\n\n', 2) + meter.feed(b'data: [DONE]\n\n', 3) + assert meter.first_content == 1 and meter.content_chars == 2 + assert cache_tokens(meter.usage) == 12 and meter.done + assert cache_tokens(None) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('provider,name', [('openai', 'gpt-4o'), ('openrouter', 'openai/gpt-4o')]) +@pytest.mark.parametrize('drain', [False, True]) +async def test_real_stream_reuses_socket_and_preserves_late_usage(monkeypatch, provider, name, drain): + monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost') + sockets = [] + + async def handle(request): + await request.read() + sockets.append(request.transport) + response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'}) + await response.prepare(request) + events = [ + {'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': 'hello'}, 'finish_reason': None}]}, + {'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]}, + { + 'choices': [], + 'usage': { + 'prompt_tokens': 14, + 'completion_tokens': 1, + 'total_tokens': 15, + 'prompt_tokens_details': {'cached_tokens': 12}, + }, + }, + ] + for event in events: + await response.write( + ( + 'data: ' + + json.dumps( + { + 'id': 'r', + 'object': 'chat.completion.chunk', + 'created': 1, + 'model': name, + **event, + } + ) + + '\n\n' + ).encode() + ) + await asyncio.sleep(0.02) + await response.write(b'data: [DO') + await response.write(b'NE]\n\n') + await response.write_eof() + return response + + app = web.Application() + app.router.add_post('/chat/completions', handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + adapter = LiteLLMRequester( + SimpleNamespace(logger=Mock()), + { + 'custom_llm_provider': provider, + 'base_url': f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}', + }, + ) + await adapter.initialize() + adapter._http_pool.drain_sse = drain + model = _runtime_model(adapter, name=name) + model.provider.token_mgr.get_token = lambda: 'test' + try: + for _ in range(2): + state = CallState(RequestPolicy()) + token = current_call.set(state) + try: + chunks = [chunk async for chunk in adapter.invoke_llm_stream(None, model, [])] + assert ''.join(chunk.content or '' for chunk in chunks) == 'hello' + assert state.usage['prompt_tokens_details']['cached_tokens'] == 12 + finally: + current_call.reset(token) + # OpenRouter's generic HTTP adapter already consumes EOF; the OpenAI + # SDK path is the regression that needs terminal draining. + assert (sockets[0] is sockets[1]) == (drain or provider == 'openrouter') + finally: + await adapter.aclose() + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_terminal_drain_is_bounded_but_early_close_does_not_drain(): + class Stream(httpx.AsyncByteStream): + def __init__(self, terminal): + self.terminal = terminal + self.tail_read = False + self.closed = False + + async def __aiter__(self): + yield b'data: [DONE]\n\n' if self.terminal else b'data: {"choices":[]}\n\n' + self.tail_read = True + await asyncio.Event().wait() + + async def aclose(self): + self.closed = True + + for terminal in (False, True): + raw = Stream(terminal) + wrapped = _CompletedSSEStream(raw) + iterator = wrapped.__aiter__() + async with asyncio.timeout(1): + await anext(iterator) + await wrapped.aclose() + await iterator.aclose() + assert raw.closed and raw.tail_read == terminal + + +@pytest.mark.asyncio +async def test_cancellation_during_terminal_drain_is_not_swallowed(): + reading = asyncio.Event() + + class Stream(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'data: [DONE]\n\n' + reading.set() + await asyncio.Event().wait() + + async def aclose(self): + pass + + wrapped = _CompletedSSEStream(Stream()) + iterator = wrapped.__aiter__() + pending = asyncio.create_task(anext(iterator)) + await reading.wait() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + await iterator.aclose() + await wrapped.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('retire', [False, True]) +async def test_concurrent_pool_initialization_loads_tls_once_off_loop(monkeypatch, retire): + context = httpx.create_ssl_context() + entered, release = threading.Event(), threading.Event() + main_thread = threading.get_ident() + calls = [] + + def load(): + assert threading.get_ident() != main_thread + calls.append(True) + entered.set() + assert release.wait(2) + return context + + monkeypatch.setattr(httpx, 'create_ssl_context', load) + pool = RequesterHTTPPool() + + async def lease(): + async with pool.lease() as client: + return client + + first, second = asyncio.create_task(lease()), asyncio.create_task(lease()) + await asyncio.to_thread(entered.wait, 2) + if retire: + await pool.aclose() + release.set() + results = await asyncio.gather(first, second, return_exceptions=True) + assert len(calls) == 1 + if retire: + assert all(isinstance(result, RequesterError) for result in results) + assert pool.client is None + else: + assert results[0] is results[1] is pool.client + assert not pool.client.is_closed + await pool.aclose() + + +@pytest.mark.asyncio +async def test_cancelling_tls_initialization_leaks_no_http_client(monkeypatch): + context = httpx.create_ssl_context() + entered, release = threading.Event(), threading.Event() + + def load(): + entered.set() + assert release.wait(2) + return context + + monkeypatch.setattr(httpx, 'create_ssl_context', load) + pool = RequesterHTTPPool() + + async def lease(): + async with pool.lease(): + pytest.fail('Cancelled initializer must not publish a client') + + pending = asyncio.create_task(lease()) + await asyncio.to_thread(entered.wait, 2) + pending.cancel() + try: + with pytest.raises(asyncio.CancelledError): + await pending + await pool.aclose() + assert pool.client is None and pool.active == 0 + finally: + release.set() + + +@pytest.mark.asyncio +async def test_real_openai_stream_close_disconnects_upstream(monkeypatch): + monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost') + disconnected = asyncio.Event() + + async def handle(request): + await request.read() + response = web.StreamResponse(headers={'Content-Type': 'text/event-stream'}) + await response.prepare(request) + await response.write( + b'data: {"id":"r","object":"chat.completion.chunk","created":1,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n' + ) + for _ in range(100): + if request.transport is None or request.transport.is_closing(): + disconnected.set() + break + await asyncio.sleep(0.02) + return response + + app = web.Application() + app.router.add_post('/chat/completions', handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + adapter = LiteLLMRequester( + SimpleNamespace(logger=Mock()), + { + 'custom_llm_provider': 'openai', + 'base_url': f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}', + }, + ) + await adapter.initialize() + model = _runtime_model(adapter, name='gpt-4o') + model.provider.token_mgr.get_token = lambda: 'test' + stream = adapter.invoke_llm_stream(None, model, []) + try: + assert (await anext(stream)).content == 'hello' + await stream.aclose() + await asyncio.wait_for(disconnected.wait(), 3) + finally: + await stream.aclose() + await adapter.aclose() + await runner.cleanup() diff --git a/tests/unit_tests/provider/test_litellmchat.py b/tests/unit_tests/provider/test_litellmchat.py index 490f7aa12..b5dff4b34 100644 --- a/tests/unit_tests/provider/test_litellmchat.py +++ b/tests/unit_tests/provider/test_litellmchat.py @@ -53,6 +53,37 @@ class MockRuntimeRerankModel: self.provider.token_mgr.get_token = Mock(return_value=api_key) +@pytest.mark.asyncio +async def test_closing_consumer_closes_provider_http_stream(): + import asyncio + from types import SimpleNamespace + + app = Mock() + app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None) + requester = litellmchat.LiteLLMRequester(ap=app, config={}) + closed = asyncio.Event() + + async def provider(): + try: + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'first'}), + finish_reason=None, + ) + ] + ) + await asyncio.Event().wait() + finally: + closed.set() + + with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: provider())): + output = requester.invoke_llm_stream(query=None, model=MockRuntimeModel(), messages=[]) + assert (await anext(output)).content == 'first' + await output.aclose() + assert closed.is_set() + + class TestBuildLiteLLMModelName: """Test _build_litellm_model_name method""" @@ -348,43 +379,33 @@ class TestInvokeLLMStreamUsage: assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer' @pytest.mark.asyncio - async def test_stream_removes_initial_orphan_think_close(self): - """Initial reasoning content without an open tag is removed until .""" - import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query - import langbot_plugin.api.entities.builtin.provider.message as provider_message - + @pytest.mark.parametrize('separate_reasoning', [False, True]) + async def test_remove_think_preserves_incremental_plain_text(self, separate_reasoning): + """Visible text must arrive before the provider produces the next delta.""" 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('minimax-m3', 'test-api-key') + model = MockRuntimeModel('gpt-4o', 'test-api-key') + consumed = [] - chunks = [ - self._make_chunk(content='hidden reasoning'), - self._make_chunk(content=' still hidden= 0 and results[0]['relevance_score'] <= 1 + assert [result['index'] for result in results] == [0, 1, 2] + assert [result['relevance_score'] for result in results] == pytest.approx([1.0, 0.0, 0.76923076923]) @pytest.mark.asyncio async def test_invoke_rerank_normalization(self): @@ -1276,8 +1295,8 @@ class TestConvertMessages: result = requester._convert_messages(messages) assert len(result) == 1 - # LiteLLM handles multiple text parts, we pass them through - assert isinstance(result[0]['content'], list) + assert result[0]['role'] == 'user' + assert result[0]['content'] == [{'type': 'text', 'text': 'Hello'}, {'type': 'text', 'text': 'World'}] class TestScanModels: @@ -1509,3 +1528,33 @@ class TestScanModels: if __name__ == '__main__': pytest.main([__file__, '-v']) + + +class TestStreamingThinkFilter: + @pytest.mark.parametrize( + 'opening,closing', + [ + ('', ''), + ('CRETIRE_REASONING_BEGINk', 'CRETIRE_REASONING_ENDk'), + ], + ) + def test_split_tags_and_repeated_blocks(self, opening, closing): + state = litellmchat._ThinkStripState() + assert state.feed('Before ') == 'Before ' + for char in opening + 'hidden' + closing: + assert state.feed(char) == '' + assert state.feed('Answer ') == 'Answer ' + assert state.feed(opening + 'more hidden' + closing + 'end') == 'end' + assert state.flush() == '' + + def test_unclosed_think_is_discarded(self): + state = litellmchat._ThinkStripState() + assert state.feed('hidden') == '' + assert state.flush() == '' + + def test_partial_non_tag_is_released(self): + state = litellmchat._ThinkStripState() + assert state.feed('Answer ') == '' + assert state.feed(' <') == ' ' + assert state.flush() == '<' diff --git a/tests/unit_tests/provider/test_llm_preparation.py b/tests/unit_tests/provider/test_llm_preparation.py new file mode 100644 index 000000000..626003982 --- /dev/null +++ b/tests/unit_tests/provider/test_llm_preparation.py @@ -0,0 +1,516 @@ +"""Connection ownership, preparation reuse and provider-specific request policy.""" + +import asyncio +import copy +import threading +import json +from pathlib import Path +from collections import OrderedDict +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +import yaml +from aiohttp import web + +from langbot.pkg.provider.modelmgr import reasoning +from langbot.pkg.provider.modelmgr.errors import RequesterError +from langbot.pkg.provider.modelmgr.http_pool import RequesterHTTPPool +from langbot.pkg.provider.modelmgr.prompt_caching import apply_prompt_caching +from langbot.pkg.provider.modelmgr.request_policy import CallState, RequestPolicy, current_call +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester +from langbot.pkg.provider.modelmgr.requesters.litellmchat import _LeasedHTTPHandler +from langbot.pkg.provider.modelmgr.requesters import codex +from langbot_plugin.api.entities.builtin.provider.message import Message +from tests.unit_tests.provider.test_reasoning_control import _runtime_model, _requester + + +@pytest.mark.asyncio +async def test_pool_retirement_waits_for_stream_lease(): + pool = RequesterHTTPPool() + async with pool.lease() as first: + async with pool.lease() as second: + assert first is second + await pool.aclose() + assert not first.is_closed + with pytest.raises(RequesterError, match='retired'): + async with pool.lease(): + pass + assert not first.is_closed + assert first.is_closed + await pool.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('provider,name', [('openai', 'gpt-4o'), ('openrouter', 'openai/gpt-4o')]) +async def test_real_http_reuses_socket_with_rotating_key_and_timeout(monkeypatch, provider, name): + monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost') + sockets, headers, bodies = [], [], [] + + async def handle(request): + sockets.append(request.transport) + headers.append(request.headers['Authorization']) + bodies.append(await request.json()) + return web.json_response( + { + 'id': 'r', + 'object': 'chat.completion', + 'created': 1, + 'model': 'gpt-4o', + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2}, + } + ) + + app = web.Application() + app.router.add_post('/v1/chat/completions', handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + adapter = LiteLLMRequester(Mock(), {'custom_llm_provider': provider, 'base_url': f'http://127.0.0.1:{port}/v1'}) + await adapter.initialize() + model = _runtime_model(adapter, name=name) + model.provider.token_mgr.get_token = Mock(side_effect=['first-key', 'second-key']) + try: + for seconds in (10, 20): + state = CallState(RequestPolicy(read_timeout=seconds), 'lb-scoped') + token = current_call.set(state) + try: + message, _ = await adapter.invoke_llm( + None, + model, + [Message(role='user', content='Hi')], + extra_args={'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}}, + ) + assert message.content == 'ok' + finally: + current_call.reset(token) + assert sockets[0] is sockets[1] + assert headers == ['Bearer first-key', 'Bearer second-key'] + assert bodies[0]['prompt_cache_key'] == bodies[1]['prompt_cache_key'] + assert bodies[0]['prompt_cache_retention'] == 'in_memory' + assert all('prompt_caching' not in body and 'reasoning_config' not in body for body in bodies) + finally: + await adapter.aclose() + await runner.cleanup() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancel', [False, True]) +async def test_stream_holds_pool_lease_until_consumer_close(cancel): + adapter = _requester('openai') + await adapter.initialize() + model = _runtime_model(adapter, name='gpt-4o') + model.provider.token_mgr.get_token = Mock(return_value='test') + closed = asyncio.Event() + + async def raw(): + try: + yield SimpleNamespace( + choices=[ + SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'hi'}), finish_reason=None) + ] + ) + await asyncio.Event().wait() + finally: + closed.set() + + with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())): + stream = adapter.invoke_llm_stream(None, model, []) + await anext(stream) + client = adapter._http_pool.client + await adapter.aclose() + assert not client.is_closed + if cancel: + pending = asyncio.create_task(anext(stream)) + await asyncio.sleep(0) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + else: + await stream.aclose() + assert closed.is_set() and client.is_closed + + +@pytest.mark.asyncio +async def test_preparation_reused_only_within_call_and_is_not_mutated(): + adapter = _requester('openai') + adapter.ap.tool_mgr = SimpleNamespace( + generate_tools_for_openai=AsyncMock( + return_value=[{'type': 'function', 'function': {'name': 'tool', 'parameters': {'type': 'object'}}}] + ) + ) + model = _runtime_model(adapter, name='gpt-4o') + model.provider.token_mgr.get_token = Mock(side_effect=['key-1', 'key-2', 'key-3']) + messages, funcs = [Message(role='user', content='original')], [object()] + state = CallState(RequestPolicy(retries=1)) + with patch.object(adapter, '_convert_messages', wraps=adapter._convert_messages) as convert: + token = current_call.set(state) + try: + first = await adapter._build_completion_args(model, messages, funcs) + first['messages'][0]['content'] = 'mutated' + first['tools'][0]['function']['name'] = 'mutated' + second = await adapter._build_completion_args(model, messages, funcs) + finally: + current_call.reset(token) + assert convert.call_count == 1 + assert adapter.ap.tool_mgr.generate_tools_for_openai.await_count == 1 + assert second['messages'][0]['content'] == 'original' + assert second['tools'][0]['function']['name'] == 'tool' + assert second['api_key'] == 'key-2' + await adapter._build_completion_args(model, messages, funcs) + assert convert.call_count == 2 + assert messages[0].content == 'original' + + +@pytest.mark.asyncio +async def test_token_counter_runs_off_event_loop(): + adapter = _requester('openai') + model = _runtime_model(adapter, name='gpt-4o') + model.provider.token_mgr.get_token = Mock(return_value='test') + main_thread = threading.get_ident() + + def count(**kwargs): + assert threading.get_ident() != main_thread + return 12 + + with patch('litellm.token_counter', new=count): + assert await adapter.count_tokens(model, []) == 12 + + +def test_metadata_cache_is_bounded_and_returns_copies(): + adapter = _requester('openai') + compute = Mock(return_value={'levels': ['low']}) + first = adapter._cached_metadata('model', compute) + first['levels'].append('bad') + assert adapter._cached_metadata('model', compute) == {'levels': ['low']} + assert compute.call_count == 1 + for index in range(150): + adapter._cached_metadata(index, lambda: index) + assert len(adapter._metadata_cache) == 128 + + +@pytest.mark.parametrize( + 'config', + [ + {'budget_tokens': True}, + {'budget_tokens': 0}, + {'budget_tokens': -1}, + {'budget_tokens': 1.5}, + {'level': 'high', 'budget_tokens': 1024}, + {'level': 'disabled', 'budget_tokens': 1024}, + ], +) +def test_invalid_reasoning_budget(config): + with pytest.raises(ValueError): + reasoning.normalize_reasoning_config(config) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'provider,name,budget,expected', + [ + ('anthropic', 'claude-sonnet-4-5', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}), + ('openai', 'qwen3-32b', 2048, {'extra_body': {'enable_thinking': True, 'thinking_budget': 2048}}), + ('gemini', 'gemini-2.5-flash', 2048, {'thinking': {'type': 'enabled', 'budget_tokens': 2048}}), + ('openrouter', 'anthropic/claude-sonnet-4.5', 2048, {'extra_body': {'reasoning': {'max_tokens': 2048}}}), + ], +) +async def test_budget_mapping_through_existing_extra_args(provider, name, budget, expected): + adapter = _requester(provider) + model = _runtime_model(adapter, name=name) + model.provider.token_mgr.get_token = Mock(return_value='test') + args = await adapter._build_completion_args( + model, [], extra_args={'reasoning_config': {'budget_tokens': budget}, 'max_tokens': 4096} + ) + for key, value in expected.items(): + assert args[key] == value + assert 'reasoning_config' not in args + assert model.reasoning_config_override is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'provider,name,budget', + [ + ('openai', 'gpt-5', 2048), + ('anthropic', 'claude-opus-4-7', 2048), + ('anthropic', 'claude-sonnet-4-5', 512), + ('gemini', 'gemini-2.5-flash', 30000), + ('gemini', 'gemini-2.5-flash-lite', 128), + ], +) +async def test_budget_not_silently_dropped_on_unsupported_model(provider, name, budget): + adapter = _requester(provider) + model = _runtime_model(adapter, name=name) + model.provider.token_mgr.get_token = Mock(return_value='test') + with pytest.raises(RequesterError, match='budget'): + await adapter._build_completion_args(model, [], extra_args={'reasoning_config': {'budget_tokens': budget}}) + + +def test_runner_level_replaces_call_and_model_budget(): + adapter = _requester('anthropic') + model = _runtime_model(adapter, name='claude-sonnet-4-5') + model.model_entity.reasoning_config = {'budget_tokens': 2048} + call = {'reasoning_config': {'budget_tokens': 4096}} + assert reasoning.resolve_reasoning_config(model, call)['budget_tokens'] == 4096 + model.reasoning_config_override = {'level': 'low'} + assert reasoning.resolve_reasoning_config(model, call) == {'level': 'low'} + + +def test_anthropic_cache_boundary_stable_and_input_unchanged(): + messages = [{'role': 'system', 'content': 'stable instructions'}, {'role': 'user', 'content': 'first'}] + original = copy.deepcopy(messages) + args = {'messages': messages, 'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'}} + apply_prompt_caching(args, None, provider='anthropic', family='anthropic') + prefix = args['messages'][0] + assert prefix['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'} + assert messages == original + assert 'cache_control' not in args['messages'][1] + args['messages'].append({'role': 'user', 'content': 'second'}) + args['prompt_caching'] = {'mode': 'anthropic'} + apply_prompt_caching(args, None, provider='anthropic', family='anthropic') + assert args['messages'][0] == prefix + + +def test_explicit_cache_settings_win_and_default_does_nothing(): + args = { + 'model': 'openai/gpt-4o', + 'extra_body': {'prompt_cache_key': 'explicit', 'prompt_cache_retention': '24h'}, + 'prompt_caching': {'mode': 'openai', 'retention': 'in_memory'}, + } + apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai') + assert 'prompt_cache_key' not in args and 'prompt_cache_retention' not in args + args = {'messages': [{'role': 'system', 'content': 'same'}], 'prompt_caching': {'mode': 'provider_default'}} + apply_prompt_caching(args, None, provider='anthropic', family='anthropic') + assert args == {'messages': [{'role': 'system', 'content': 'same'}]} + + +def test_native_cache_options_bypass_model_filter_without_legacy_hints(): + args = { + 'model': 'openai/gpt-test', + 'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'}, + 'prompt_caching': {'mode': 'openai', 'retention': '24h'}, + } + apply_prompt_caching(args, CallState(RequestPolicy(), 'lb-scoped'), provider='openai', family='openai') + assert args == { + 'model': 'openai/gpt-test', + 'extra_body': {'prompt_cache_options': {'mode': 'implicit', 'ttl': '30m'}}, + } + + +@pytest.mark.parametrize( + 'config', + [ + {'mode': 'typo'}, + {'mode': 'anthropic', 'ttl': '24h'}, + {'mode': 'openai', 'retention': 'forever'}, + {'mode': 'provider_default', 'ttl': '1h'}, + ], +) +def test_invalid_cache_config(config): + with pytest.raises(RequesterError): + apply_prompt_caching( + {'model': 'test', 'messages': [], 'prompt_caching': config}, None, provider='openai', family='openai' + ) + + +@pytest.mark.asyncio +async def test_gateway_transport_never_retries_or_closes_borrowed_client(): + calls = 0 + + async def fail(request): + nonlocal calls + calls += 1 + raise httpx.ConnectError('unavailable', request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(fail)) as client: + handler = _LeasedHTTPHandler(client, 10) + with pytest.raises(httpx.ConnectError): + await handler.post('https://example.com', json={}) + await handler.close() + assert calls == 1 and not client.is_closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize('provider', ['anthropic', 'openrouter', 'openrouter-metadata']) +async def test_anthropic_cache_and_budget_reach_upstream(provider): + bodies = [] + + async def handle(request): + bodies.append(json.loads(request.content)) + if provider == 'anthropic': + payload = { + 'id': 'msg_test', + 'type': 'message', + 'role': 'assistant', + 'model': 'claude-sonnet-4-5', + 'content': [{'type': 'text', 'text': 'ok'}], + 'stop_reason': 'end_turn', + 'usage': {'input_tokens': 1, 'output_tokens': 1, 'cache_read_input_tokens': 50}, + } + else: + payload = { + 'id': 'r', + 'object': 'chat.completion', + 'created': 1, + 'model': 'anthropic/claude-sonnet-4.5', + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}], + 'usage': { + 'prompt_tokens': 51, + 'completion_tokens': 1, + 'total_tokens': 52, + 'prompt_tokens_details': {'cached_tokens': 50}, + }, + } + return httpx.Response(200, json=payload) + + adapter = _requester('openai' if provider == 'openrouter-metadata' else provider) + if provider == 'openrouter-metadata': + metadata = yaml.safe_load( + ( + Path(__file__).parents[3] / 'src/langbot/pkg/provider/modelmgr/requesters/openrouterchatcmpl.yaml' + ).read_text(encoding='utf-8') + ) + adapter.requester_cfg.update( + requester_name=metadata['metadata']['name'], custom_llm_provider=metadata['spec']['litellm_provider'] + ) + name = 'claude-sonnet-4-5' if provider == 'anthropic' else 'anthropic/claude-sonnet-4.5' + model = _runtime_model(adapter, name=name) + model.provider.token_mgr.get_token = Mock(return_value='test') + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: + result, usage = await adapter.invoke_llm( + None, + model, + [ + Message(role='system', content='stable'), + Message(role='user', content='hello'), + ], + extra_args={ + 'client': _LeasedHTTPHandler(client, 10), + 'reasoning_config': {'budget_tokens': 2048}, + 'max_tokens': 4096, + 'prompt_caching': {'mode': 'anthropic', 'ttl': '1h'}, + }, + ) + assert result.content == 'ok' + assert usage + body = bodies[0] + if provider == 'anthropic': + assert body['system'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'} + assert body['thinking']['budget_tokens'] == 2048 + else: + assert body['messages'][0]['content'][-1]['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'} + assert body['reasoning']['max_tokens'] == 2048 + assert 'reasoning_config' not in body and 'prompt_caching' not in body + + +@pytest.mark.asyncio +async def test_codex_reuses_real_connection(monkeypatch): + monkeypatch.setenv('NO_PROXY', '127.0.0.1,localhost') + sockets = [] + + async def handle(request): + sockets.append(request.transport) + await request.read() + events = [ + {'type': 'response.output_text.delta', 'delta': 'ok'}, + {'type': 'response.completed', 'response': {'id': 'r', 'status': 'completed', 'output': []}}, + ] + return web.Response( + text=''.join('data: ' + json.dumps(event) + '\n\n' for event in events), content_type='text/event-stream' + ) + + app = web.Application() + app.router.add_post('/responses', handle) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + monkeypatch.setattr(codex, 'BASE_URL', f'http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}') + adapter = object.__new__(codex.CodexRequester) + adapter.workspace, adapter.provider = 'w', 'p' + adapter._replay = OrderedDict() + adapter._http_pool = RequesterHTTPPool() + adapter.auth = SimpleNamespace( + access=AsyncMock( + return_value={ + 'access_token': 'test', + 'account_id': 'a', + 'connection_id': 'c', + } + ) + ) + model = SimpleNamespace(model_entity=SimpleNamespace(name='test', extra_args={}, reasoning_config=None)) + try: + for _ in range(2): + message, _ = await adapter.invoke_llm(None, model, []) + assert message.content == 'ok' + assert sockets[0] is sockets[1] + finally: + await adapter.aclose() + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_explicit_client_and_redirected_provider_are_preserved(): + adapter = _requester('openai') + await adapter.initialize() + explicit = object() + for args in ({'model': 'openai/gpt-4o', 'client': explicit}, {'model': 'anthropic/claude-sonnet-4-5'}): + async with adapter._client_args(args) as result: + assert result is args + assert adapter._http_pool.client is None + await adapter.aclose() + + +@pytest.mark.asyncio +async def test_codex_terminal_tail_is_bounded_and_releases_stream(): + closed = asyncio.Event() + + class HangingTail(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'data: {"type":"response.completed"}\n\n' + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, stream=HangingTail())) + ) as client: + async with client.stream('GET', 'https://example.com') as response: + events = [event async for event in codex.sse_events(response)] + assert events == [{'type': 'response.completed'}] + assert closed.is_set() + + +@pytest.mark.asyncio +async def test_custom_tls_keeps_litellm_transport(monkeypatch): + import litellm + + adapter = _requester('openai') + await adapter.initialize() + monkeypatch.setattr(litellm, 'ssl_verify', '/custom/ca.pem') + args = {'model': 'openai/gpt-4o'} + async with adapter._client_args(args) as result: + assert result is args + assert adapter._http_pool.client is None + await adapter.aclose() + + +@pytest.mark.asyncio +async def test_pooled_sdk_preserves_environment_fallbacks(monkeypatch): + monkeypatch.setenv('OPENAI_BASE_URL', 'https://gateway.example/v1') + monkeypatch.setenv('OPENAI_API_KEY', 'env-test-key') + adapter = _requester('openai') + await adapter.initialize() + try: + async with adapter._client_args({'model': 'openai/gpt-4o', 'api_key': ''}) as args: + assert str(args['client'].base_url) == 'https://gateway.example/v1/' + assert args['client'].api_key == 'env-test-key' + finally: + await adapter.aclose() diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py index 0ba164e9a..97b136d56 100644 --- a/tests/unit_tests/provider/test_mcp_resources.py +++ b/tests/unit_tests/provider/test_mcp_resources.py @@ -24,7 +24,6 @@ from langbot.pkg.provider.tools.loaders.mcp import ( MCPToolCallTimeoutError, RuntimeMCPSession, ) -from langbot.pkg.telemetry import features as telemetry_features from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError @@ -298,10 +297,6 @@ async def test_read_resource_envelope_truncates_caches_and_records_trace(): 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 diff --git a/tests/unit_tests/provider/test_provider_specific_fields.py b/tests/unit_tests/provider/test_provider_specific_fields.py index fe239f3ea..803754066 100644 --- a/tests/unit_tests/provider/test_provider_specific_fields.py +++ b/tests/unit_tests/provider/test_provider_specific_fields.py @@ -4,15 +4,17 @@ This includes provider_specific_fields round-trip coverage for GitHub issue #1899 and token counting preflight behavior for Runner context budgeting. """ +import json from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch +import httpx import pytest import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from langbot.pkg.provider.modelmgr import requester as model_requester -from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester, _LeasedHTTPHandler def _make_requester() -> LiteLLMRequester: @@ -148,6 +150,132 @@ def test_convert_messages_preserves_message_provider_specific_fields(): assert out[0]['provider_specific_fields']['thought_signatures'] == ['sig1', 'sig2'] +@pytest.mark.parametrize('family', ['', 'openai', 'gemini', 'groq']) +def test_response_finish_reason_is_not_replayed(family): + req = _make_requester() + msg = provider_message.Message( + role='assistant', content='Hello', provider_specific_fields={'finish_reason': 'stop'} + ) + + assert req._convert_messages([msg], reasoning_family=family) == [{'role': 'assistant', 'content': 'Hello'}] + assert msg.provider_specific_fields == {'finish_reason': 'stop'} + + +def test_response_metadata_removal_preserves_gemini_signatures(): + req = _make_requester() + msg = provider_message.Message( + role='assistant', + content='Hello', + provider_specific_fields={'finish_reason': 'stop', 'thought_signatures': ['signature']}, + ) + + out = req._convert_messages([msg], reasoning_family='gemini') + + assert out[0]['provider_specific_fields'] == {'thought_signatures': ['signature']} + + +@pytest.fixture +def replayed_tool_message(): + return provider_message.Message( + role='assistant', + content='Hello', + provider_specific_fields={'finish_reason': 'tool_calls', 'other_response_metadata': True}, + tool_calls=[ + provider_message.ToolCall( + id='call-1', + type='function', + function=provider_message.FunctionCall(name='search', arguments='{}'), + provider_specific_fields={'thought_signature': 'signature'}, + ) + ], + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stream', [False, True]) +@pytest.mark.parametrize('chat_provider,family', [('groq', 'openai'), ('gemini', 'groq'), ('openrouter', 'groq')]) +async def test_completion_metadata_adaptation_uses_chat_provider(stream, chat_provider, family, replayed_tool_message): + req = _make_configured_requester() + req.requester_cfg['custom_llm_provider'] = chat_provider + model = _make_runtime_model() + model.model_entity.name = 'openai/gpt-oss-120b' + msg = replayed_tool_message + original = msg.model_dump() + + with patch.object(req, '_reasoning_family', return_value=family): + args = await req._build_completion_args(model, [msg], None, {}, stream=stream) + + assert args['model'] == f'{chat_provider}/openai/gpt-oss-120b' + expected = msg.model_dump(exclude_none=True) + expected['provider_specific_fields'].pop('finish_reason') + if chat_provider == 'groq': + expected.pop('provider_specific_fields') + expected['tool_calls'][0].pop('provider_specific_fields') + assert args['messages'] == [expected] + assert msg.model_dump() == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize('stream', [False, True]) +async def test_groq_http_request_excludes_replayed_metadata(stream, replayed_tool_message): + req = _make_configured_requester() + req.requester_cfg.update(custom_llm_provider='groq', base_url='https://groq.test/openai/v1') + model = _make_runtime_model() + model.model_entity.name = 'openai/gpt-oss-120b' + msg = replayed_tool_message + original = msg.model_dump() + bodies = [] + + def handle(request): + assert request.url == 'https://groq.test/openai/v1/chat/completions' + bodies.append(json.loads(request.content)) + response = { + 'id': 'completion-1', + 'object': 'chat.completion', + 'created': 1, + 'model': 'openai/gpt-oss-120b', + 'service_tier': 'on_demand', + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'Done'}, 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, 'total_tokens': 2}, + } + if stream: + response['object'] = 'chat.completion.chunk' + response['choices'][0]['delta'] = response['choices'][0].pop('message') + return httpx.Response( + 200, + headers={'content-type': 'text/event-stream'}, + content=f'data: {json.dumps(response)}\n\ndata: [DONE]\n\n', + ) + return httpx.Response(200, json=response) + + messages = [ + provider_message.Message(role='user', content='Search'), + msg, + provider_message.Message(role='tool', tool_call_id='call-1', content='Found'), + ] + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: + extra_args = {'client': _LeasedHTTPHandler(client, 10)} + if stream: + chunks = [chunk async for chunk in req.invoke_llm_stream(None, model, messages, extra_args=extra_args)] + assert ''.join(chunk.content or '' for chunk in chunks) == 'Done' + else: + result, _ = await req.invoke_llm(None, model, messages, extra_args=extra_args) + assert result.content == 'Done' + + assert len(bodies) == 1 + assert bodies[0]['model'] == 'openai/gpt-oss-120b' + assert bodies[0]['messages'] == [ + {'role': 'user', 'content': 'Search'}, + { + 'role': 'assistant', + 'content': 'Hello', + 'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}], + }, + {'role': 'tool', 'tool_call_id': 'call-1', 'content': 'Found'}, + ] + assert msg.model_dump() == original + + def test_normalize_stream_tool_calls_preserves_provider_specific_fields(): """Streaming tool calls should retain provider_specific_fields.""" req = _make_requester() diff --git a/tests/unit_tests/provider/test_request_policy.py b/tests/unit_tests/provider/test_request_policy.py new file mode 100644 index 000000000..c8b0eb448 --- /dev/null +++ b/tests/unit_tests/provider/test_request_policy.py @@ -0,0 +1,549 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import litellm +import pytest + +from langbot.pkg.provider.modelmgr.errors import RequesterError +from langbot.pkg.provider.modelmgr.request_policy import ( + CallState, + RequestPolicy, + current_call, + error_metadata, + opaque_conversation_id, +) +from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester +from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk +from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT +from tests.unit_tests.provider.test_litellmchat import MockRuntimeModel + + +def test_policy_precedence_zero_and_no_mutation(): + model = {'num_retries': 3, 'request_policy': {'read_timeout': 42}} + policy = RequestPolicy.resolve({'request_policy': {'retries': 2}}, model, {'num_retries': 0}) + assert policy.retries == 0 + assert policy.read_timeout == 42 + assert model['num_retries'] == 3 + + +@pytest.mark.parametrize( + 'policy', + [ + {'retries': True}, + {'retries': -1}, + {'retries': 6}, + {'read_timeout': 0}, + {'total_timeout': float('nan')}, + {'connect_timeout': float('inf')}, + {'attribution': 'false'}, + {'conversation_header': 'authorization'}, + {'typo': 1}, + ], +) +def test_invalid_policy(policy): + with pytest.raises(RequesterError, match='Model request failed|模型请求失败'): + RequestPolicy.resolve({'request_policy': policy}) + + +def test_retry_metadata_does_not_include_upstream_secrets(): + response = httpx.Response(429, headers={'retry-after': '2', 'x-request-id': 'req-123'}) + meta = error_metadata(SimpleNamespace(status_code=429, response=response)) + assert meta == {'status_code': 429, 'retry_after': 2, 'request_id': 'req-123'} + adapter = LiteLLMRequester(Mock(), {}) + exc = litellm.RateLimitError('secret-key-and-prompt', llm_provider='openai', model='test', response=response) + with pytest.raises(RequesterError) as caught: + adapter._handle_litellm_error(exc) + assert caught.value.retryable + assert caught.value.request_id == 'req-123' + assert 'secret-key' not in str(caught.value) + + +def test_quota_is_not_retryable(): + adapter = LiteLLMRequester(Mock(), {}) + exc = litellm.RateLimitError('quota', llm_provider='openai', model='test') + exc.body = {'error': {'code': 'insufficient_quota'}} + with pytest.raises(RequesterError) as caught: + adapter._handle_litellm_error(exc) + assert caught.value.code == 'quota_exceeded' + assert not caught.value.retryable + + +@pytest.mark.asyncio +async def test_headers_and_sdk_retry_ownership(): + adapter = LiteLLMRequester(Mock(), {'base_url': 'https://openrouter.ai/api/v1'}) + state = CallState(RequestPolicy(retries=2), 'lb-opaque') + token = current_call.set(state) + try: + args = await adapter._build_completion_args( + MockRuntimeModel(), + [], + extra_args={ + 'request_policy': {'retries': 2}, + 'num_retries': 2, + 'extra_headers': {'x-openrouter-title': 'Custom app', 'x-other': 'keep'}, + }, + ) + finally: + current_call.reset(token) + assert 'request_policy' not in args + assert args['num_retries'] == args['max_retries'] == 0 + assert isinstance(args['timeout'], httpx.Timeout) + assert args['extra_headers'] == { + 'x-openrouter-title': 'Custom app', + 'x-other': 'keep', + 'HTTP-Referer': 'https://langbot.app', + 'x-session-id': 'lb-opaque', + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize('url', ['https://gateway.example/v1', 'https://openrouter.ai.evil.example/v1']) +async def test_no_vendor_headers_on_unrelated_hosts(url): + adapter = LiteLLMRequester(Mock(), {'base_url': url}) + args = await adapter._build_completion_args(MockRuntimeModel(), []) + assert not args.get('extra_headers') + assert args['num_retries'] == 0 + + +def test_conversation_is_stable_and_scoped(): + query = SimpleNamespace(session=None, launcher_type='person', launcher_id='123', bot_uuid='bot', pipeline_uuid='p') + first = opaque_conversation_id(query, TEST_EXECUTION_CONTEXT) + assert first == opaque_conversation_id(query, TEST_EXECUTION_CONTEXT) + query.bot_uuid = 'another-bot' + assert first != opaque_conversation_id(query, TEST_EXECUTION_CONTEXT) + assert opaque_conversation_id(None, TEST_EXECUTION_CONTEXT) is None + + +@pytest.mark.asyncio +async def test_runtime_retries_only_before_output(runtime_provider, runtime_llm_model): + provider = runtime_provider + attempts = 0 + closed = [] + + async def stream(**kwargs): + nonlocal attempts + attempts += 1 + try: + if attempts == 1: + raise RequesterError('transient', retryable=True) + yield MessageChunk(role='assistant', content='first') + raise RequesterError('after output', retryable=True) + finally: + closed.append(attempts) + + provider.requester.invoke_llm_stream = stream + result = [] + with pytest.raises(RequesterError, match='after output'): + async for chunk in provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'retries': 3, 'backoff_base': 0.001}}, + execution_context=TEST_EXECUTION_CONTEXT, + ): + result.append(chunk.content) + assert result == ['first'] + assert attempts == 2 + assert closed == [1, 2] + assert current_call.get() is None + + +@pytest.mark.asyncio +async def test_runtime_deadline_and_close(runtime_provider, runtime_llm_model): + closed = asyncio.Event() + + async def stream(**kwargs): + try: + await asyncio.Event().wait() + yield MessageChunk(role='assistant', content='never') + finally: + closed.set() + + runtime_provider.requester.invoke_llm_stream = stream + with pytest.raises(RequesterError) as caught: + async for _ in runtime_provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'first_event_timeout': 0.02}}, + execution_context=TEST_EXECUTION_CONTEXT, + ): + pass + assert caught.value.phase == 'first_event' + assert closed.is_set() + + +@pytest.mark.asyncio +async def test_retry_after_cannot_extend_total_budget(): + state = CallState(RequestPolicy(retries=5, total_timeout=0.1)) + state.metrics['attempts'] = 1 + assert not await state.retry(RequesterError('busy', retryable=True, retry_after=30)) + + +@pytest.mark.asyncio +async def test_cancellation_is_not_retried(runtime_provider, runtime_llm_model): + invoke = AsyncMock(side_effect=asyncio.CancelledError) + runtime_provider.requester.invoke_llm = invoke + with pytest.raises(asyncio.CancelledError): + await runtime_provider.invoke_llm( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'retries': 3}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + assert invoke.await_count == 1 + + +@pytest.mark.asyncio +async def test_complete_usage_and_timings_persist(runtime_provider, runtime_llm_model): + from langbot_plugin.api.entities.builtin.pipeline.query import Query + + query = Query.model_construct( + query_id=1, launcher_type='person', launcher_id=1, variables={}, bot_uuid='bot', pipeline_uuid='p', session=None + ) + object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT) + usage = { + 'prompt_tokens': 20, + 'completion_tokens': 10, + 'total_tokens': 30, + 'prompt_tokens_details': {'cached_tokens': 15}, + 'completion_tokens_details': {'reasoning_tokens': 8}, + 'cost': 0.01, + } + runtime_provider.requester.invoke_llm = AsyncMock(return_value=(Message(role='assistant', content='ok'), usage)) + with patch( + 'langbot.pkg.pipeline.monitoring_helper.MonitoringHelper.record_llm_call', new_callable=AsyncMock + ) as record: + result = await runtime_provider.invoke_llm(query, runtime_llm_model, []) + assert result.content == 'ok' + details = record.call_args.kwargs['details'] + assert details['usage'] == usage + assert details['timing']['attempts'] == 1 + assert details['timing']['total_ms'] >= details['timing']['response_completed_ms'] + assert query.variables['_llm_usage'] == usage + + +@pytest.mark.asyncio +async def test_hidden_reasoning_marks_raw_event_and_usage(): + state = CallState(RequestPolicy()) + adapter = LiteLLMRequester(Mock(), {}) + + async def raw(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace(model_dump=lambda: {'reasoning_content': 'hidden'}), finish_reason=None + ) + ] + ) + yield SimpleNamespace(choices=[], usage={'prompt_tokens': 3, 'completion_tokens': 2, 'total_tokens': 5}) + + token = current_call.set(state) + try: + with patch( + 'langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw()) + ): + chunks = [c async for c in adapter.invoke_llm_stream(None, MockRuntimeModel(), [], remove_think=True)] + finally: + current_call.reset(token) + assert state.raw_started + assert 'first_event_ms' in state.metrics and 'first_reasoning_ms' in state.metrics + assert 'first_content_ms' not in state.metrics + assert state.usage['total_tokens'] == 5 + assert not any(c.content for c in chunks) + + +@pytest.mark.asyncio +async def test_real_http_retry_count_and_request_body(runtime_provider, runtime_llm_model): + """Exercise the pinned LiteLLM/OpenAI SDK against a local HTTP server.""" + from aiohttp import web + + received = [] + + async def handler(request): + received.append(await request.json()) + if len(received) == 1: + return web.json_response({'error': {'message': 'busy', 'type': 'server_error'}}, status=503) + return web.json_response( + { + 'id': 'chatcmpl-local', + 'object': 'chat.completion', + 'created': 1, + 'model': 'gpt-4o', + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 4, 'completion_tokens': 1, 'total_tokens': 5}, + } + ) + + app = web.Application() + app.router.add_post('/v1/chat/completions', handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + runtime_provider.requester = LiteLLMRequester( + Mock(), + { + 'base_url': f'http://127.0.0.1:{port}/v1', + 'custom_llm_provider': 'openai', + }, + ) + runtime_llm_model.model_entity.name = 'gpt-4o' + runtime_llm_model.model_entity.extra_args = {} + try: + with pytest.raises(RequesterError): + await runtime_provider.invoke_llm( + None, + runtime_llm_model, + [Message(role='user', content='hello')], + extra_args={'num_retries': 0}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + assert len(received) == 1 + received.clear() + result = await runtime_provider.invoke_llm( + None, + runtime_llm_model, + [Message(role='user', content='hello')], + extra_args={'request_policy': {'retries': 1, 'backoff_base': 0.001}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + assert result.content == 'ok' + assert len(received) == 2 + assert all('request_policy' not in body and 'num_retries' not in body for body in received) + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_stream_idle_timeout_after_output(runtime_provider, runtime_llm_model): + async def stream(**kwargs): + yield MessageChunk(role='assistant', content='first') + await asyncio.Event().wait() + + runtime_provider.requester.invoke_llm_stream = stream + with pytest.raises(RequesterError) as caught: + async for _ in runtime_provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'stream_idle_timeout': 0.02}}, + execution_context=TEST_EXECUTION_CONTEXT, + ): + pass + assert caught.value.phase == 'stream_idle' + + +def test_details_migration_preserves_existing_records_and_is_idempotent(): + import importlib + import sqlalchemy as sa + from alembic.migration import MigrationContext + from alembic.operations import Operations + + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0038_llm_call_details') + engine = sa.create_engine('sqlite://') + try: + with engine.begin() as connection: + connection.exec_driver_sql('CREATE TABLE monitoring_llm_calls (id TEXT PRIMARY KEY)') + connection.exec_driver_sql("INSERT INTO monitoring_llm_calls (id) VALUES ('old')") + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + migration.upgrade() + assert connection.exec_driver_sql('SELECT id, details FROM monitoring_llm_calls').one() == ('old', None) + table = sa.Table('monitoring_llm_calls', sa.MetaData(), autoload_with=connection) + payload = {'usage': {'completion_tokens_details': {'reasoning_tokens': 8}}, 'timing': {'first_event_ms': 1}} + connection.execute(table.insert().values(id='new', details=payload)) + assert connection.execute(sa.select(table.c.details).where(table.c.id == 'new')).scalar() == payload + finally: + engine.dispose() + + +@pytest.mark.asyncio +async def test_hidden_raw_events_reset_idle_not_first_visible_deadline(runtime_provider, runtime_llm_model): + adapter = LiteLLMRequester(Mock(), {}) + runtime_provider.requester = adapter + runtime_llm_model.model_entity.extra_args = {} + + async def raw(): + for _ in range(5): + await asyncio.sleep(0.3) + yield SimpleNamespace( + choices=[ + SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'role': 'assistant'}), finish_reason=None) + ] + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'answer'}), finish_reason='stop') + ] + ) + + with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=AsyncMock(return_value=raw())): + chunks = [ + chunk + async for chunk in runtime_provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'first_event_timeout': 1, 'stream_idle_timeout': 2}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + ] + assert ''.join(chunk.content or '' for chunk in chunks) == 'answer' + + +@pytest.mark.asyncio +async def test_monitoring_service_persists_full_details(): + from langbot.pkg.api.http.service.monitoring import MonitoringService + + persistence = SimpleNamespace(execute_async=AsyncMock()) + service = MonitoringService(SimpleNamespace(persistence_mgr=persistence)) + details = { + 'usage': { + 'prompt_tokens': 4, + 'completion_tokens': 3, + 'total_tokens': 9, + 'cache_read_input_tokens': 2, + 'provider_extra': {'cost': 0.01}, + }, + 'timing': {'attempts': 2}, + 'error': None, + } + await service.record_llm_call( + TEST_EXECUTION_CONTEXT, + bot_id='b', + bot_name='b', + pipeline_id='p', + pipeline_name='p', + session_id='s', + model_name='m', + input_tokens=4, + output_tokens=3, + duration=10, + details=details, + ) + values = persistence.execute_async.call_args.args[0].compile().params + assert values['details'] == details + assert values['total_tokens'] == 9 + + +def test_codex_usage_preserves_provider_fields(): + from langbot.pkg.provider.modelmgr.requesters.codex import _usage + + raw = { + 'input_tokens': 4, + 'output_tokens': 2, + 'cost': 0.02, + 'output_tokens_details': {'reasoning_tokens': 1}, + 'future_counter': 7, + } + normalized = _usage({'usage': raw}) + assert all(normalized[key] == value for key, value in raw.items()) + assert normalized['total_tokens'] == 6 + + +@pytest.mark.asyncio +async def test_nonstream_uses_response_budget_not_first_event(): + adapter = LiteLLMRequester(Mock(), {}) + state = CallState(RequestPolicy(first_event_timeout=0.001, total_timeout=1)) + + async def completion(**kwargs): + await asyncio.sleep(0.02) + return 'complete' + + token = current_call.set(state) + try: + with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion): + assert await adapter._completion({'stream': False}) == 'complete' + finally: + current_call.reset(token) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancel_read', [False, True]) +async def test_litellm_stream_close_and_cancel_release_upstream(runtime_provider, runtime_llm_model, cancel_read): + adapter = LiteLLMRequester(Mock(), {}) + runtime_provider.requester = adapter + runtime_llm_model.model_entity.extra_args = {} + closed = asyncio.Event() + reading = asyncio.Event() + + async def raw(): + try: + yield SimpleNamespace( + choices=[ + SimpleNamespace(delta=SimpleNamespace(model_dump=lambda: {'content': 'first'}), finish_reason=None) + ] + ) + reading.set() + await asyncio.Event().wait() + finally: + closed.set() + + completion = AsyncMock(return_value=raw()) + with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.acompletion', new=completion): + stream = runtime_provider.invoke_llm_stream( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'retries': 3}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + assert (await anext(stream)).content == 'first' + if cancel_read: + task = asyncio.create_task(anext(stream)) + await reading.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + await stream.aclose() + assert closed.is_set() + assert completion.await_count == 1 + assert current_call.get() is None + + +@pytest.mark.asyncio +async def test_cancel_during_backoff_does_not_start_another_attempt(runtime_provider, runtime_llm_model): + runtime_provider.requester.invoke_llm = AsyncMock(side_effect=RequesterError('busy', retryable=True)) + sleeping = asyncio.Event() + + async def backoff(delay): + sleeping.set() + await asyncio.Event().wait() + + with patch('langbot.pkg.provider.modelmgr.request_policy.asyncio.sleep', new=backoff): + task = asyncio.create_task( + runtime_provider.invoke_llm( + None, + runtime_llm_model, + [], + extra_args={'request_policy': {'retries': 3}}, + execution_context=TEST_EXECUTION_CONTEXT, + ) + ) + await sleeping.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert runtime_provider.requester.invoke_llm.await_count == 1 + + +@pytest.mark.asyncio +async def test_legacy_none_chunk_is_ignored(runtime_provider, runtime_llm_model): + async def stream(**kwargs): + yield None + yield MessageChunk(role='assistant', content='ok') + + runtime_provider.requester.invoke_llm_stream = stream + chunks = [ + chunk + async for chunk in runtime_provider.invoke_llm_stream( + None, runtime_llm_model, [], execution_context=TEST_EXECUTION_CONTEXT + ) + ] + assert [chunk.content for chunk in chunks] == ['ok'] diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py index 4ed1335fb..e909a1a24 100644 --- a/tests/unit_tests/provider/test_requester_base.py +++ b/tests/unit_tests/provider/test_requester_base.py @@ -56,11 +56,8 @@ def test_requester_base_class_is_abstract(): mock_app = SimpleNamespace() mock_app.logger = Mock() - # ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation - # if you don't call the abstract methods. Test that it has abstract methods. - assert hasattr(requester.ProviderAPIRequester, 'invoke_llm') - # Check that invoke_llm is abstract - assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__') + with pytest.raises(TypeError, match='abstract'): + requester.ProviderAPIRequester(mock_app, {}) def test_requester_default_config_merged(): @@ -250,16 +247,6 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data assert isinstance(provider.requester, requester.ProviderAPIRequester) -def test_runtime_provider_has_invoke_methods(runtime_provider): - """Test RuntimeProvider has invoke methods that delegate to requester.""" - provider = runtime_provider - - assert hasattr(provider, 'invoke_llm') - assert hasattr(provider, 'invoke_llm_stream') - assert hasattr(provider, 'invoke_embedding') - assert hasattr(provider, 'invoke_rerank') - - @pytest.mark.asyncio async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model): """Test RuntimeProvider.invoke_llm delegates to requester.""" @@ -404,6 +391,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider assert len(chunks) == 1 assert chunks[0].role == 'assistant' + assert [(part.type, part.text) for part in chunks[0].content] == [('text', 'Fake stream chunk')] @pytest.mark.asyncio @@ -513,13 +501,11 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da assert model.reasoning_config_override is None -def test_runtime_llm_model_provider_ref(runtime_llm_model): +def test_runtime_llm_model_provider_ref(runtime_llm_model, runtime_provider): """Test RuntimeLLMModel has correct provider reference.""" model = runtime_llm_model - assert model.provider.provider_entity is not None - assert model.provider.token_mgr is not None - assert model.provider.requester is not None + assert model.provider is runtime_provider # ============================================================================ @@ -563,7 +549,7 @@ def test_requester_error_message_format(): """Test RequesterError message format.""" error = RequesterError('API returned 500') - assert '模型请求失败' in str(error) + assert 'Model request failed' in str(error) assert 'API returned 500' in str(error) diff --git a/tests/unit_tests/provider/test_session_manager.py b/tests/unit_tests/provider/test_session_manager.py index 469743c5b..39d5c835a 100644 --- a/tests/unit_tests/provider/test_session_manager.py +++ b/tests/unit_tests/provider/test_session_manager.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest import asyncio from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, AsyncMock from importlib import import_module import langbot_plugin.api.entities.builtin.provider.session as provider_session @@ -26,6 +26,13 @@ from langbot.pkg.pipeline.pool import ( ) +@pytest.fixture(autouse=True) +def reset_generation(monkeypatch): + lookup = AsyncMock(return_value=None) + monkeypatch.setattr('langbot.pkg.agent.runner.context_reset.get_reset_generation', lookup) + return lookup + + TEST_CONTEXT = ExecutionContext( instance_uuid='instance-test', workspace_uuid='workspace-test', @@ -358,7 +365,10 @@ class TestSessionManagerGetConversation: ) assert conversation.prompt.name == 'default' - assert len(conversation.prompt.messages) == 2 + assert [(message.role, message.content) for message in conversation.prompt.messages] == [ + ('system', 'System message'), + ('user', 'User message'), + ] class TestSessionManagerWorkspaceIsolation: @@ -447,7 +457,7 @@ class TestSessionManagerWorkspaceIsolation: sessions.append(await manager.get_session(query)) assert len(manager.session_list) == 2 - assert sessions[0] not in manager.session_list + assert all(session is not sessions[0] for session in manager.session_list) assert sessions[1:] == manager.session_list assert await manager.get_session(queries[-1]) is manager.session_list[-1] @@ -556,3 +566,18 @@ class TestSessionManagerWorkspaceIsolation: content = conversation.messages[0].content assert content[1].image_base64 is None assert content[2].file_base64 is None + + +@pytest.mark.asyncio +async def test_reset_generation_replaces_cached_session(reset_generation): + app = Mock() + app.instance_config.data = {'concurrency': {'session': 5}} + manager = get_session_module().SessionManager(app) + query = scoped_query() + previous = await manager.get_session(query) + assert await manager.get_session(query) is previous + reset_generation.return_value = 'new-context' + fresh = await manager.get_session(query) + assert fresh is not previous + assert not fresh.conversations + assert await manager.get_session(query) is fresh diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 29af1a0b4..2c2f49c24 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -68,21 +68,18 @@ def _make_skill_data( 'description': kwargs.pop('description', f'Description of {name}'), 'instructions': instructions, 'package_root': package_root, + 'manifest_path': kwargs.pop('manifest_path', ''), 'entry_file': entry_file, + 'revision': kwargs.pop('revision', 'sha256:' + '1' * 64), **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.""" + """SkillManager caches the Core-owned SkillRepository catalog.""" 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.""" + """Disk mutations are reflected by an explicit repository reload.""" from langbot.pkg.skill.manager import SkillManager ap = _make_ap() @@ -99,119 +96,26 @@ class TestSkillManagerCache: assert mgr.refresh_skill_from_disk(_CONTEXT, '') 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.""" + async def test_reload_skills_uses_repository_when_box_is_disabled(self): 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(_CONTEXT) - - assert list(mgr.get_skills(_CONTEXT)) == ['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, + repository = SimpleNamespace( 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'), + _make_skill_data(name='alpha', package_root='/skills/alpha'), + _make_skill_data(name='beta', package_root='/skills/beta'), ] ), ) - ap = _make_ap() - ap.box_service = box_service + ap.box_service = SimpleNamespace(available=False, enabled=False) + ap.skill_repository = repository mgr = SkillManager(ap) await mgr.reload_skills(_CONTEXT) assert sorted(mgr.get_skills(_CONTEXT)) == ['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_by_scope[mgr._scope_key(_CONTEXT)] = { - 'primary': _make_skill_data(name='primary', instructions='Primary instructions'), - } - ap.skill_mgr = mgr - - query = _make_query() - - 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_by_scope[mgr._scope_key(_CONTEXT)] = {'primary': _make_skill_data(name='primary')} - ap.skill_mgr = mgr - - query = _make_query() - - 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 = _make_query() - - assert register_activated_skill(ap, query, 'primary') is False + repository.list_skills.assert_awaited_once_with(_CONTEXT) class TestSkillPathHelpers: @@ -231,28 +135,16 @@ class TestSkillPathHelpers: 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, - ) + def test_activation_keeps_first_revision_and_rejects_unversioned_data(self): + from langbot.pkg.provider.tools.loaders.skill import register_activated_skill - ap = _make_ap() - ap.skill_mgr = _make_skill_manager( - { - 'visible': _make_skill_data(name='visible'), - 'hidden': _make_skill_data(name='hidden'), - } - ) - query = _make_query(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'] + query = _make_query() + first = _make_skill_data(name='visible', revision='sha256:' + '1' * 64) + second = _make_skill_data(name='visible', revision='sha256:' + '2' * 64) + assert register_activated_skill(query, first)['revision'] == first['revision'] + assert register_activated_skill(query, second)['revision'] == first['revision'] + with pytest.raises(ValueError, match='immutable revision'): + register_activated_skill(query, {'name': 'unversioned'}) def test_resolve_virtual_skill_path_allows_visible_skill_reads(self): from langbot.pkg.provider.tools.loaders.skill import ( @@ -329,13 +221,40 @@ class TestSkillPathHelpers: class TestSkillToolLoader: - """The skill tool surface is now just ``activate`` + ``register_skill``. + """Skill activation and resources are independent from sandbox execution. 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_frozen_skill_source_runs_activation_without_box(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader + from langbot.pkg.provider.tools.toolmgr import ToolManager + + skill = _make_skill_data(name='demo', instructions='Read this first') + ap = _make_ap() + ap.skill_mgr = _make_skill_manager({'demo': skill}) + ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill)) + ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False)) + manager = ToolManager(ap) + manager.skill_tool_loader = SkillToolLoader(ap) + query = _make_query(_execution_context=_CONTEXT) + source_ref = {'source': 'skill', 'source_id': None} + + result = await manager.execute_func_call('activate', {'skill_name': 'demo'}, query, source_ref) + + assert result['activated'] is True + assert result['revision'] == skill['revision'] + assert result['capabilities']['execution_available'] is False + assert query.variables['_skill_execution_available'] is False + assert await manager.get_tool_by_source(_CONTEXT, 'activate', source_ref) is not None + assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is None + + ap.box_service.is_workspace_sandbox_available.return_value = True + assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is not None + @pytest.mark.asyncio async def test_activate_returns_instructions_and_registers_skill(self): from langbot.pkg.provider.tools.loaders.skill_authoring import ( @@ -345,8 +264,11 @@ class TestSkillToolLoader: 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') + skill['revision'] = 'sha256:demo' ap = _make_ap() ap.skill_mgr = _make_skill_manager({'demo': skill}) + ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill)) + ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False)) loader = SkillToolLoader(ap) query = _make_query() @@ -355,11 +277,46 @@ class TestSkillToolLoader: assert result['activated'] is True assert result['skill_name'] == 'demo' - assert result['mount_path'] == '/workspace/.skills/demo' + assert result['mount_path'] is None + assert result['revision'] == 'sha256:demo' + assert result['capabilities']['resources_readable'] is True + assert result['capabilities']['execution_available'] is False assert result['activated_skill_names'] == ['demo'] assert 'Step 1' in result['content'] + assert '' not in result['content'] assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'} + @pytest.mark.asyncio + async def test_reactivation_keeps_first_revision_and_instructions(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader + + v1 = _make_skill_data( + name='demo', + instructions='version one', + revision='sha256:' + '1' * 64, + ) + v2 = _make_skill_data( + name='demo', + instructions='version two', + revision='sha256:' + '2' * 64, + ) + ap = _make_ap() + ap.skill_mgr = _make_skill_manager({'demo': v1}) + ap.skill_repository = SimpleNamespace( + get_skill=AsyncMock(side_effect=[v1, v2]), + ) + ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False)) + loader = SkillToolLoader(ap) + query = _make_query() + + first = await loader.invoke_tool('activate', {'skill_name': 'demo'}, query) + second = await loader.invoke_tool('activate', {'skill_name': 'demo'}, query) + + assert first['revision'] == v1['revision'] + assert second['revision'] == v1['revision'] + assert 'version one' in second['content'] + assert 'version two' not in second['content'] + @pytest.mark.asyncio async def test_activate_unknown_skill_raises(self): from langbot.pkg.provider.tools.loaders.skill_authoring import ( @@ -391,7 +348,11 @@ class TestSkillToolLoader: os.makedirs(repo_dir) ap = _make_ap() - ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) + ap.box_service = SimpleNamespace( + default_workspace=tmpdir, + available=True, + require_workspace_sandbox=AsyncMock(return_value=_CONTEXT), + ) ap.skill_service = SimpleNamespace( scan_directory_async=AsyncMock( return_value={ @@ -401,7 +362,7 @@ class TestSkillToolLoader: 'instructions': 'Do work', } ), - create_skill=AsyncMock( + import_skill_directory=AsyncMock( return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir)) ), ) @@ -414,20 +375,64 @@ class TestSkillToolLoader: ) ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir)) - ap.skill_service.create_skill.assert_awaited_once_with( + ap.skill_service.import_skill_directory.assert_awaited_once_with( _CONTEXT, + os.path.realpath(repo_dir), { 'name': 'cloned-skill', 'display_name': 'Cloned Skill', 'description': 'Imported from clone', 'instructions': 'Do work', - 'package_root': os.path.realpath(repo_dir), }, + base_revision=None, ) 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_forwards_base_revision_for_update(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + REGISTER_SKILL_TOOL_NAME, + SkillToolLoader, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + draft = os.path.join(tmpdir, 'skill-drafts', 'demo') + os.makedirs(draft) + revision = 'sha256:' + '3' * 64 + ap = _make_ap() + ap.box_service = SimpleNamespace( + default_workspace=tmpdir, + available=True, + require_workspace_sandbox=AsyncMock(return_value=_CONTEXT), + ) + ap.skill_service = SimpleNamespace( + scan_directory_async=AsyncMock( + return_value={ + 'name': 'demo', + 'display_name': 'Demo', + 'description': 'Updated', + 'instructions': 'v2', + } + ), + import_skill_directory=AsyncMock( + return_value=_make_skill_data(name='demo', revision='sha256:' + '4' * 64) + ), + ) + + result = await SkillToolLoader(ap).invoke_tool( + REGISTER_SKILL_TOOL_NAME, + { + 'path': '/workspace/skill-drafts/demo', + 'base_revision': revision, + }, + _make_query(), + ) + + assert result['revision'] == 'sha256:' + '4' * 64 + assert ap.skill_service.import_skill_directory.await_args.kwargs['base_revision'] == revision + @pytest.mark.asyncio async def test_register_skill_rejects_workspace_escape(self): from langbot.pkg.provider.tools.loaders.skill_authoring import ( @@ -437,8 +442,15 @@ class TestSkillToolLoader: 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()) + ap.box_service = SimpleNamespace( + default_workspace=tmpdir, + available=True, + require_workspace_sandbox=AsyncMock(return_value=_CONTEXT), + ) + ap.skill_service = SimpleNamespace( + scan_directory_async=AsyncMock(), + import_skill_directory=AsyncMock(), + ) loader = SkillToolLoader(ap) @@ -458,7 +470,11 @@ class TestSkillToolLoader: with tempfile.TemporaryDirectory() as tmpdir: ap = _make_ap() # no skill_service attribute - ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) + ap.box_service = SimpleNamespace( + default_workspace=tmpdir, + available=True, + require_workspace_sandbox=AsyncMock(return_value=_CONTEXT), + ) loader = SkillToolLoader(ap) @@ -470,21 +486,22 @@ class TestSkillToolLoader: ) @pytest.mark.asyncio - async def test_tools_hidden_when_sandbox_backend_unavailable(self): + async def test_read_only_tools_remain_when_sandbox_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_backend_status=AsyncMock(return_value={'backend': {'available': False}}), - ) + ap.skill_repository = SimpleNamespace() loader = SkillToolLoader(ap) await loader.initialize() - assert await loader.get_tools() == [] - assert await loader.has_tool('activate') is False + assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [ + 'activate', + 'list_skill_resources', + 'read_skill_resource', + ] + assert await loader.has_tool('activate') is True assert await loader.has_tool('register_skill') is False @pytest.mark.asyncio @@ -493,45 +510,96 @@ class TestSkillToolLoader: ap = _make_ap() ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')}) - ap.box_service = SimpleNamespace( - available=True, - get_backend_status=AsyncMock(return_value={'backend': {'available': True}}), - ) + ap.skill_repository = SimpleNamespace() loader = SkillToolLoader(ap) await loader.initialize() - tools = await loader.get_tools() + tools = await loader.get_tools(sandbox_available=True) - assert sorted(tool.name for tool in tools) == ['activate', 'register_skill'] + assert sorted(tool.name for tool in tools) == [ + 'activate', + 'list_skill_resources', + 'read_skill_resource', + 'register_skill', + ] assert await loader.has_tool('activate') is True - assert await loader.has_tool('register_skill') is True + assert await loader.has_tool('register_skill', sandbox_available=True) is True @pytest.mark.asyncio - async def test_tools_reappear_after_box_backend_recovers(self): + async def test_register_skill_appears_after_sandbox_recovers(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=False, - get_backend_status=AsyncMock(return_value={'backend': {'available': True}}), - ) + ap.skill_repository = SimpleNamespace() loader = SkillToolLoader(ap) await loader.initialize() - assert await loader.get_tools() == [] + assert 'register_skill' not in {tool.name for tool in await loader.get_tools(sandbox_available=False)} + assert 'register_skill' in {tool.name for tool in await loader.get_tools(sandbox_available=True)} - ap.box_service.available = True + @pytest.mark.asyncio + async def test_resources_require_activation_and_use_pinned_revision(self): + from langbot.pkg.provider.tools.loaders.skill import register_activated_skill + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader - assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill'] + skill = _make_skill_data(name='demo', instructions='Read references') + skill['revision'] = 'sha256:demo' + ap = _make_ap() + ap.skill_mgr = _make_skill_manager({'demo': skill}) + ap.skill_repository = SimpleNamespace( + list_skill_resources=AsyncMock( + return_value={'entries': [{'path': 'references/a.md'}], 'revision': 'sha256:demo'} + ), + read_skill_resource=AsyncMock( + return_value={ + 'path': 'references/a.md', + 'content': 'reference text', + 'revision': 'sha256:demo', + 'mime_type': 'text/markdown', + } + ), + ) + loader = SkillToolLoader(ap) + query = _make_query() + + with pytest.raises(ValueError, match='must be activated'): + await loader.invoke_tool( + 'read_skill_resource', + {'skill_name': 'demo', 'path': 'references/a.md'}, + query, + ) + + register_activated_skill(query, skill) + listed = await loader.invoke_tool('list_skill_resources', {'skill_name': 'demo'}, query) + read = await loader.invoke_tool( + 'read_skill_resource', + {'skill_name': 'demo', 'path': 'references/a.md', 'revision': 'sha256:demo'}, + query, + ) + + assert listed['entries'][0]['path'] == 'references/a.md' + assert read['content'] == 'reference text' + ap.skill_repository.list_skill_resources.assert_awaited_once_with( + _CONTEXT, + 'demo', + '.', + expected_revision='sha256:demo', + ) + ap.skill_repository.read_skill_resource.assert_awaited_once_with( + _CONTEXT, + 'demo', + 'references/a.md', + expected_revision='sha256:demo', + ) 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 + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill with tempfile.TemporaryDirectory() as tmpdir: skill_md = os.path.join(tmpdir, 'SKILL.md') @@ -547,20 +615,22 @@ class TestNativeToolLoaderSkillPaths: ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)}) loader = NativeToolLoader(ap) + query = _make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}) + register_activated_skill(query, ap.skill_mgr.skills['demo']) if not loader._can_interpret_skill_host_paths(): # Windows lacks the secure descriptor-relative host file operations. - with pytest.raises(ValueError, match='owned by the Box Runtime'): + with pytest.raises(ValueError, match='Secure Core host file operations'): await loader.invoke_tool( 'read', {'path': '/workspace/.skills/demo/SKILL.md'}, - _make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}), + query, ) return result = await loader.invoke_tool( 'read', {'path': '/workspace/.skills/demo/SKILL.md'}, - _make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}), + query, ) assert result['ok'] is True @@ -568,9 +638,9 @@ class TestNativeToolLoaderSkillPaths: assert result['truncated'] is False @pytest.mark.asyncio - async def test_external_runtime_read_never_interprets_package_root_on_core_host(self): + async def test_external_runtime_read_uses_core_skill_repository(self): from langbot.pkg.provider.tools.loaders.native import NativeToolLoader - from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill with tempfile.TemporaryDirectory() as tmpdir: with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj: @@ -580,7 +650,9 @@ class TestNativeToolLoaderSkillPaths: ap.box_service = SimpleNamespace( available=True, shares_filesystem_with_box=False, - read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}), + ) + ap.skill_repository = SimpleNamespace( + read_skill_resource=AsyncMock(return_value={'content': 'repository-content'}) ) ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)}) loader = NativeToolLoader(ap) @@ -588,6 +660,7 @@ class TestNativeToolLoaderSkillPaths: query_id='q-external-read', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}, ) + register_activated_skill(query, ap.skill_mgr.skills['demo']) result = await loader.invoke_tool( 'read', @@ -596,14 +669,19 @@ class TestNativeToolLoaderSkillPaths: ) assert result['ok'] is True - assert result['content'] == 'runtime-owned-content' + assert result['content'] == 'repository-content' assert 'core-host-secret' not in repr(result) - ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md') + ap.skill_repository.read_skill_resource.assert_awaited_once_with( + _CONTEXT, + 'demo', + 'SKILL.md', + expected_revision='sha256:' + '1' * 64, + ) @pytest.mark.asyncio - async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self): + async def test_core_owned_skill_path_does_not_depend_on_runtime_topology(self): from langbot.pkg.provider.tools.loaders.native import NativeToolLoader - from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill with tempfile.TemporaryDirectory() as tmpdir: with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj: @@ -620,34 +698,48 @@ class TestNativeToolLoaderSkillPaths: query_id='q-external-no-protocol', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}, ) + register_activated_skill(query, ap.skill_mgr.skills['demo']) - with pytest.raises(ValueError, match='owned by the Box Runtime'): - await loader.invoke_tool( - 'grep', - { - 'path': '/workspace/.skills/demo', - 'pattern': 'core-host-secret', - }, - query, - ) + result = await loader.invoke_tool( + 'grep', + { + 'path': '/workspace/.skills/demo', + 'pattern': 'core-host-secret', + }, + query, + ) + + assert result['ok'] is True + assert result['total'] == 1 @pytest.mark.asyncio - async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self): + async def test_exec_in_activated_skill_mount_rewrites_command_without_mutating_skill(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: + manifest_path = os.path.join(tmpdir, 'manifest.json') + with open(manifest_path, 'w', encoding='utf-8') as file: + file.write('{}') 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()) + skill_data = _make_skill_data( + name='demo', + package_root=tmpdir, + manifest_path=manifest_path, + ) + ap.skill_mgr = _make_skill_manager( + {'demo': skill_data}, + refresh_skill_from_disk=Mock(), + ) loader = NativeToolLoader(ap) query = _make_bound_query(query_id='q1', launcher_type='person', launcher_id='123') - register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir)) + register_activated_skill(query, skill_data) result = await loader.invoke_tool( 'exec', @@ -660,50 +752,56 @@ class TestNativeToolLoaderSkillPaths: 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' - assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo' - ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo') + assert tool_parameters['command'] == f'python /opt/langbot-artifacts/{"1" * 64}/scripts/run.py' + assert tool_parameters['workdir'] == '/opt/langbot-artifacts/' + '1' * 64 + assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs + ap.skill_mgr.refresh_skill_from_disk.assert_not_called() @pytest.mark.asyncio async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self): from langbot.pkg.provider.tools.loaders.native import NativeToolLoader from langbot.pkg.provider.tools.loaders.skill import register_activated_skill - ap = _make_ap() - ap.box_service = SimpleNamespace( - available=True, - shares_filesystem_with_box=False, - execute_tool=AsyncMock(return_value={'ok': True}), - ) - ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) - loader = NativeToolLoader(ap) - query = _make_bound_query(query_id='q-external', launcher_type='person', launcher_id='123') - register_activated_skill( - query, - _make_skill_data( + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = os.path.join(tmpdir, 'manifest.json') + with open(manifest_path, 'w', encoding='utf-8') as file: + file.write('{}') + ap = _make_ap() + ap.box_service = SimpleNamespace( + available=True, + shares_filesystem_with_box=False, + execute_tool=AsyncMock(return_value={'ok': True}), + ) + skill_data = _make_skill_data( name='demo', - package_root='/box-runtime/skills/tenants/workspace/demo', + package_root=tmpdir, + manifest_path=manifest_path, python_project=True, - ), - ) + ) + ap.skill_mgr = _make_skill_manager( + {'demo': skill_data}, + refresh_skill_from_disk=Mock(), + ) + loader = NativeToolLoader(ap) + query = _make_bound_query(query_id='q-external', launcher_type='person', launcher_id='123') + register_activated_skill(query, skill_data) - result = await loader.invoke_tool( - 'exec', - { - 'command': 'python /workspace/.skills/demo/scripts/run.py', - 'workdir': '/workspace/.skills/demo', - }, - query, - ) + 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] wrapped = tool_parameters['command'] - assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped - assert 'root = "/workspace/.skills/demo"' in wrapped - assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped - assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo' + assert f'_LB_VENV_DIR="/workspace/.skill-envs/demo/{"1" * 64}/.venv"' in wrapped + assert f'root = "/opt/langbot-artifacts/{"1" * 64}"' in wrapped + assert tmpdir not in wrapped + assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs @pytest.mark.asyncio async def test_write_requires_skill_activation(self): @@ -725,6 +823,36 @@ class TestNativeToolLoaderSkillPaths: query, ) + @pytest.mark.asyncio + @pytest.mark.parametrize('tool_name', ['write', 'edit']) + async def test_published_revision_rejects_direct_mutation(self, tool_name): + 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) + skill = _make_skill_data(name='demo', package_root=tmpdir) + ap.skill_mgr = _make_skill_manager({'demo': skill}) + loader = NativeToolLoader(ap) + query = _make_bound_query(query_id='immutable') + register_activated_skill(query, skill) + parameters = ( + {'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'} + if tool_name == 'write' + else { + 'path': '/workspace/.skills/demo/notes.txt', + 'old_string': 'old', + 'new_string': 'new', + } + ) + + result = await loader.invoke_tool(tool_name, parameters, query) + + assert result['ok'] is False + assert 'immutable' in result['error'] + assert '/workspace/skill-drafts' in result['error'] + @pytest.mark.asyncio async def test_native_skill_tools_require_runner_box_binding(): @@ -738,21 +866,51 @@ async def test_native_skill_tools_require_runner_box_binding(): await loader.invoke_tool('exec', {'command': 'true'}, _make_query()) ap.box_service.execute_tool.assert_not_awaited() - @pytest.mark.asyncio - async def test_register_skill_hidden_when_cloud_scanning_is_disabled(self): - from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader - ap = _make_ap() - ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')}) - ap.box_service = SimpleNamespace( - available=True, - managed_admission_required=True, - get_backend_status=AsyncMock(return_value={'backend': {'available': True}}), - ) +@pytest.mark.asyncio +async def test_register_skill_hidden_when_cloud_scanning_is_disabled(): + from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader - loader = SkillToolLoader(ap) - await loader.initialize() + ap = _make_ap() + ap.skill_repository = SimpleNamespace() + ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')}) + ap.box_service = SimpleNamespace( + available=True, + managed_admission_required=True, + get_backend_status=AsyncMock(return_value={'backend': {'available': True}}), + ) - assert [tool.name for tool in await loader.get_tools()] == ['activate'] - assert await loader.has_tool('register_skill') is False - assert await loader.has_tool('activate') is True + loader = SkillToolLoader(ap) + await loader.initialize() + + assert {tool.name for tool in await loader.get_tools(sandbox_available=True)} == { + 'activate', + 'list_skill_resources', + 'read_skill_resource', + } + assert await loader.has_tool('register_skill', sandbox_available=True) is False + assert await loader.has_tool('activate') is True + + +@pytest.mark.asyncio +async def test_catalog_refresh_does_not_hide_skills_from_another_run(): + import asyncio + from langbot.pkg.skill.manager import SkillManager + + app = _make_ap() + entered, release = asyncio.Event(), asyncio.Event() + + async def read_catalog(context): + entered.set() + await release.wait() + return [_make_skill_data(name='new')] + + app.skill_repository = SimpleNamespace(list_skills=read_catalog) + manager = SkillManager(app) + manager._skills_by_scope[manager._scope_key(_CONTEXT)] = {'old': _make_skill_data(name='old')} + refresh = asyncio.create_task(manager.reload_skills(_CONTEXT)) + await entered.wait() + assert set(manager.get_skills(_CONTEXT)) == {'old'} + release.set() + await refresh + assert set(manager.get_skills(_CONTEXT)) == {'new'} diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py index d404400a4..0e81ae5e2 100644 --- a/tests/unit_tests/provider/test_tool_manager.py +++ b/tests/unit_tests/provider/test_tool_manager.py @@ -7,6 +7,8 @@ Tests cover: from __future__ import annotations +import asyncio +import contextvars from importlib import import_module from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -380,6 +382,127 @@ class TestToolManagerExecuteFuncCall: assert 'MCP resource agent reads are disabled' in record.await_args.kwargs['error_message'] +class _RecordingTelemetrySender: + """Telemetry sender stand-in that keeps every payload it was handed.""" + + def __init__(self): + self.telemetry_config = {'url': 'https://space.example.test', 'execution_trace': 'all'} + self.sent: list[dict] = [] + + async def send(self, payload: dict) -> bool: + self.sent.append(payload) + return True + + +class TestToolCallTracing: + """Every tool call a Run makes is its own step of the execution chain.""" + + @pytest.fixture + def traced_app(self): + execution = import_module('langbot.pkg.telemetry.execution') + counters = execution.ExecutionCounters(_RecordingTelemetrySender()) + app = Mock() + app.logger = Mock() + app.telemetry = SimpleNamespace(execution=counters) + # Tool-call monitoring is asserted by the tests above; this path only + # needs the execution chain. + app.monitoring_service = None + + def _inert_loader(): + loader = Mock() + loader.has_tool = AsyncMock(return_value=False) + loader.invoke_tool = AsyncMock(return_value=None) + return loader + + app._native_loader = _inert_loader() + app._plugin_loader = _inert_loader() + app._mcp_loader = _inert_loader() + app._skill_loader = _inert_loader() + return app, counters + + @pytest.fixture + def sample_query(self): + query = Mock(spec=pipeline_query.Query) + query._execution_context = _CONTEXT + return query + + @staticmethod + def _manager(app): + """ToolManager wired to app-local inert loaders.""" + manager = get_toolmgr_module().ToolManager(app) + manager.native_tool_loader = app._native_loader + manager.plugin_tool_loader = app._plugin_loader + manager.mcp_tool_loader = app._mcp_loader + manager.skill_tool_loader = app._skill_loader + return manager + + @staticmethod + def _tool_nodes(counters): + return [record for record in counters.records if record['event_type'] == 'execution_node'] + + @pytest.mark.asyncio + async def test_a_tool_call_becomes_a_node_under_the_step_that_issued_it(self, traced_app, sample_query): + trace = import_module('langbot.pkg.telemetry.trace') + execution = import_module('langbot.pkg.telemetry.execution') + app, counters = traced_app + manager = self._manager(app) + + binding = execution.bind_trace(app, 'exec-tool') + # The Runtime's RPC task executes the call with no inherited trace + # context; the Run id on the query is what resolves the owning chain. + sample_query._agent_run_session = {'run_id': 'exec-tool'} + try: + with trace.stage_scope() as lane: + foreign = contextvars.Context() + task = asyncio.get_running_loop().create_task( + manager._invoke_tool_with_monitoring( + source='native', + name='exec', + parameters={'command': 'ls'}, + query=sample_query, + invoke=AsyncMock(return_value='sandbox output'), + ), + context=foreign, + ) + assert await task == 'sandbox output' + finally: + trace.unbind_root(binding) + + rows = self._tool_nodes(counters) + assert len(rows) == 1 + assert rows[0]['family'] == 'tool' + assert rows[0]['operation'] == 'exec' + assert rows[0]['adapter'] == 'native' + assert rows[0]['outcome'] == 'success' + assert rows[0]['parent_node_id'] == lane + + @pytest.mark.asyncio + async def test_a_failed_tool_call_carries_its_reason(self, traced_app, sample_query): + execution = import_module('langbot.pkg.telemetry.execution') + trace = import_module('langbot.pkg.telemetry.trace') + app, counters = traced_app + manager = self._manager(app) + + binding = execution.bind_trace(app, 'exec-tool-failed') + try: + with pytest.raises(RuntimeError): + await manager._invoke_tool_with_monitoring( + source='mcp', + name='read_resource', + parameters={}, + query=sample_query, + invoke=AsyncMock(side_effect=RuntimeError('server unreachable')), + ) + finally: + trace.unbind_root(binding) + + rows = self._tool_nodes(counters) + assert len(rows) == 1 + assert rows[0]['family'] == 'tool' + assert rows[0]['outcome'] == 'failed' + assert rows[0]['error'] == 'server unreachable' + + class TestToolManagerSourceResolution: @pytest.mark.asyncio @pytest.mark.parametrize('source', ['mcp', 'plugin']) diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index f9f9bb879..767e1d7b6 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -56,10 +56,20 @@ class StubLoader: for tool in self._tools ] - async def has_tool(self, *args) -> bool: + async def get_tool(self, name: str, **_kwargs): + return next((tool for tool in self._tools if tool.name == name), None) + + async def has_tool(self, *args, **_kwargs) -> bool: name = args[-1] return any(tool.name == name for tool in self._tools) + def recognizes_tool(self, name: str) -> bool: + return any(tool.name == name for tool in self._tools) + + @staticmethod + def is_sandbox_tool(name: str) -> bool: + return name == 'register_skill' + 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 @@ -146,7 +156,7 @@ async def test_tool_manager_routes_native_tool_calls(): @pytest.mark.asyncio -async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement(): +async def test_tool_manager_keeps_read_only_skill_tools_without_workspace_entitlement(): box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False)) manager = ToolManager(SimpleNamespace(box_service=box_service)) manager.native_tool_loader = StubLoader([make_tool('exec')]) @@ -157,8 +167,8 @@ async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_enti tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True) catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True) - assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool'] - assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool'] + assert [tool.name for tool in tools] == ['activate', 'plugin_tool', 'mcp_tool'] + assert [item['name'] for item in catalog] == ['activate', 'plugin_tool', 'mcp_tool'] assert box_service.is_workspace_sandbox_available.await_count == 2 @@ -177,9 +187,10 @@ async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocat query_uuid=None, ) - with pytest.raises(Exception, match='exec'): - await manager.execute_func_call('exec', {'command': 'pwd'}, query=query) + result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query) + assert result['code'] == 'sandbox_unavailable' + assert result['tool'] == 'exec' box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT) diff --git a/tests/unit_tests/quality/test_required_contract_report.py b/tests/unit_tests/quality/test_required_contract_report.py new file mode 100644 index 000000000..79add8c29 --- /dev/null +++ b/tests/unit_tests/quality/test_required_contract_report.py @@ -0,0 +1,128 @@ +"""Exercise the required-suite policy through real pytest collection and execution.""" + +from pathlib import Path +import os +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +PASSING = """ +import pytest + +@pytest.mark.security +@pytest.mark.contract('debug_exec_absent') +@pytest.mark.parametrize('value', [1, 2, 3]) +def test_renamed_behavior(value): + assert value > 0 + +@pytest.mark.security +@pytest.mark.contract('debug_route_absent') +def test_another_name(): + pass + +def test_unrelated(): + assert False +""" + + +def run_suite(tmp_path, source, *args): + (tmp_path / 'moved').mkdir(exist_ok=True) + (tmp_path / 'moved/test_any_name.py').write_text(source) + (tmp_path / 'pytest.ini').write_text('[pytest]\nmarkers =\n security: suite\n contract(name): behavior\n') + return subprocess.run( + [ + sys.executable, + '-m', + 'pytest', + '-p', + 'tests.utils.required_suites', + '--required-suite', + 'security', + '-q', + *args, + ], + cwd=tmp_path, + env={**os.environ, 'PYTHONPATH': str(ROOT)}, + capture_output=True, + text=True, + timeout=30, + ) + + +def test_renames_moves_and_parameter_growth_need_no_ci_edit(tmp_path): + result = run_suite(tmp_path, PASSING) + assert result.returncode == 0, result.stdout + result.stderr + assert '4 passed, 1 deselected' in result.stdout + result = run_suite(tmp_path, PASSING.replace('[1, 2, 3]', '[1, 2, 3, 4]')) + assert result.returncode == 0, result.stdout + result.stderr + assert '5 passed, 1 deselected' in result.stdout + + +@pytest.mark.parametrize( + 'statement', + [ + "pytest.skip('dependency unavailable')", + "pytest.xfail('known bug')", + 'assert False', + "raise RuntimeError('broken')", + ], +) +def test_required_cases_must_actually_pass(tmp_path, statement): + result = run_suite(tmp_path, PASSING.replace(' pass', f' {statement}')) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout + + +def test_unexpected_xpass_is_not_a_required_pass(tmp_path): + source = PASSING.replace('def test_another_name():', '@pytest.mark.xfail(strict=False)\ndef test_another_name():') + result = run_suite(tmp_path, source) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout + + +def test_missing_behavior_cannot_be_replaced_by_more_unrelated_cases(tmp_path): + source = PASSING.replace("@pytest.mark.contract('debug_route_absent')", '') + result = run_suite(tmp_path, source) + assert result.returncode != 0 + assert "missing contracts: ['debug_route_absent']" in result.stderr + + +def test_empty_suite_fails(tmp_path): + result = run_suite(tmp_path, 'def test_unrelated(): pass') + assert result.returncode != 0 + assert 'missing contracts' in result.stderr + + +@pytest.mark.parametrize('args', [('-k', 'renamed'), ('-m', 'security'), ('--deselect', 'moved/test_any_name.py')]) +def test_required_suite_rejects_partial_filters(tmp_path, args): + result = run_suite(tmp_path, PASSING, *args) + assert result.returncode != 0 + assert 'cannot be combined' in result.stderr + + +@pytest.mark.parametrize('phase', ['setup', 'teardown']) +def test_fixture_skips_also_fail(tmp_path, phase): + fixture = '\n@pytest.fixture(autouse=True)\ndef dependency():\n' + if phase == 'teardown': + fixture += ' yield\n' + fixture += " pytest.skip('dependency unavailable')\n" + result = run_suite(tmp_path, PASSING + fixture) + assert result.returncode != 0 + assert 'all selected cases must pass' in result.stdout + + +def test_optional_local_execution_still_allows_skips(tmp_path): + (tmp_path / 'test_optional.py').write_text("import pytest\ndef test_optional(): pytest.skip('no database')") + result = subprocess.run( + [sys.executable, '-m', 'pytest', '-p', 'tests.utils.required_suites', '-q'], + cwd=tmp_path, + env={**os.environ, 'PYTHONPATH': str(ROOT)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert '1 skipped' in result.stdout diff --git a/tests/unit_tests/rag/test_file_storage.py b/tests/unit_tests/rag/test_file_storage.py index 8886e221a..f658218fe 100644 --- a/tests/unit_tests/rag/test_file_storage.py +++ b/tests/unit_tests/rag/test_file_storage.py @@ -300,7 +300,9 @@ class TestStoreFileTask: task_context.set_current_action.assert_called_once_with('Processing file') kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with(object_key) kb._ingest_document.assert_awaited_once() - assert kb.ap.persistence_mgr.execute_async.await_count == 2 + statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list] + assert [params['status'] for params in statements] == ['processing', 'completed'] + assert statements[-1]['engine_document_id'] == 'file-uuid' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key) @pytest.mark.asyncio @@ -316,7 +318,9 @@ class TestStoreFileTask: with pytest.raises(Exception, match='parser failed'): await kb._store_file_task(CONTEXT, file_obj, task_context) - assert kb.ap.persistence_mgr.execute_async.await_count == 2 + statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list] + assert [params['status'] for params in statements] == ['processing', 'failed'] + assert statements[-1]['engine_document_id'] == 'file-uuid' kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key) diff --git a/tests/unit_tests/rag/test_kbmgr.py b/tests/unit_tests/rag/test_kbmgr.py index 9ea0c54c9..01f9a9225 100644 --- a/tests/unit_tests/rag/test_kbmgr.py +++ b/tests/unit_tests/rag/test_kbmgr.py @@ -662,7 +662,9 @@ class TestRuntimeKnowledgeBaseRetrieve: assert len(results) == 1 assert results[0].id == 'doc1' - assert hasattr(results[0], 'content') + assert results[0].content[0].text == 'test content' + assert results[0].metadata == {'source': 'file.pdf'} + assert results[0].distance == 0.15 class TestRuntimeKnowledgeBaseDispose: diff --git a/tests/unit_tests/storage/test_inline_media_cleanup.py b/tests/unit_tests/storage/test_inline_media_cleanup.py new file mode 100644 index 000000000..e3aa1bafe --- /dev/null +++ b/tests/unit_tests/storage/test_inline_media_cleanup.py @@ -0,0 +1,356 @@ +"""Historical image TTL preserves records and makes bounded, durable progress.""" + +import datetime as dt +import importlib +import json +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.api.http.service.maintenance import MaintenanceService +from langbot.pkg.entity.persistence.agent_run import AgentRun, AgentRunEvent +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.event_log import EventLog +from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage +from langbot.pkg.entity.persistence.transcript import Transcript +from langbot.pkg.entity.persistence.workspace import Workspace +from langbot.pkg.persistence.mgr import PersistenceManager +from langbot.pkg.storage import inline_media_cleanup as cleanup +from langbot.pkg.storage.media import MediaCache + + +WORKSPACE_A = '00000000-0000-0000-0000-00000000000a' +WORKSPACE_B = '00000000-0000-0000-0000-00000000000b' +CONTEXT = ExecutionContext(instance_uuid='instance', workspace_uuid=WORKSPACE_A, placement_generation=1) +NOW = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) +OLD = NOW - dt.timedelta(days=40) +PAYLOAD = json.dumps( + { + 'content': [ + {'type': 'Plain', 'text': 'Keep the message'}, + {'type': 'Image', 'base64': 'eA==', 'url': 'https://upstream/image'}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'Image', 'url': '/api/v1/files/media/already-externalized.png'}, + ], + 'custom': {'count': 0, 'enabled': False}, + } +) +SOURCES = [MonitoringMessage, EventLog, AgentRun, AgentRunEvent, Transcript] + + +@pytest.fixture +async def application(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "inline-media.db"}') + ap = SimpleNamespace(logger=logging.getLogger(__name__), instance_config=SimpleNamespace(data={})) + ap.persistence_mgr = PersistenceManager(ap) + ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await conn.execute( + sa.insert(Workspace), + [ + { + 'uuid': workspace, + 'instance_uuid': 'instance', + 'name': workspace, + 'slug': workspace, + 'source': 'cloud_projection', + } + for workspace in (WORKSPACE_A, WORKSPACE_B) + ], + ) + yield ap + await engine.dispose() + + +async def insert_record(ap, model, number, workspace=WORKSPACE_A, timestamp=OLD, payload=PAYLOAD): + if model is MonitoringMessage: + values = dict( + id=str(number), + workspace_uuid=workspace, + timestamp=timestamp, + message_content=payload, + bot_id='bot', + bot_name='Bot', + pipeline_id='pipeline', + pipeline_name='Pipeline', + session_id='session', + status='success', + level='info', + ) + field = 'message_content' + elif model is AgentRunEvent: + run_id = f'parent-{number}' + await ap.persistence_mgr.execute_async( + sa.insert(AgentRun).values( + run_id=run_id, runner_id='runner', workspace_id=workspace, status='completed', created_at=timestamp + ) + ) + values = dict( + id=number, run_id=run_id, sequence=1, type='message.completed', created_at=timestamp, data_json=payload + ) + field = 'data_json' + else: + values = dict(id=number, workspace_id=workspace, created_at=timestamp) + if model is EventLog: + values.update( + event_id=f'event-{number}', event_type='message.received', source='platform', input_json=payload + ) + field = 'input_json' + elif model is AgentRun: + values.update( + run_id=f'run-{number}', + runner_id='runner', + status='completed', + metadata_json=payload, + updated_at=timestamp, + ) + field = 'metadata_json' + else: + values.update( + transcript_id=f'transcript-{number}', + event_id=f'event-{number}', + conversation_id='conversation', + role='user', + item_type='message', + seq=number, + content_json=payload, + ) + field = 'content_json' + # Seed the pre-contract database format without applying today's write policy. + values[field] = sa.bindparam('historical_payload', payload, type_=sa.Text()) + await ap.persistence_mgr.execute_async(sa.insert(model).values(**values)) + return field + + +async def read_record(ap, model, number, field): + return ( + await ap.persistence_mgr.execute_async(sa.select(getattr(model, field)).where(model.id == number)) + ).scalar_one() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('model', SOURCES, ids=lambda model: model.__tablename__) +async def test_ttl_expires_only_old_images_in_the_current_workspace(application, model): + field = await insert_record(application, model, 1) + await insert_record(application, model, 2, timestamp=NOW) + await insert_record(application, model, 3, workspace=WORKSPACE_B) + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + saved = json.loads(await read_record(application, model, 1, field)) + assert not MediaCache.has_inline_images(saved) + assert saved['content'][0]['text'] == 'Keep the message' + assert saved['content'][1]['url'] == 'https://upstream/image' + assert saved['content'][-1]['url'].startswith('/api/v1/files/media/') + assert saved['custom'] == {'count': 0, 'enabled': False} + assert await read_record(application, model, 2, field) == PAYLOAD + assert await read_record(application, model, 3, field) == PAYLOAD + if model is AgentRun: + assert await read_record(application, model, 1, 'updated_at') == OLD + + +@pytest.mark.asyncio +async def test_row_budget_resumes_persisted_progress_and_later_expiry(application, monkeypatch): + monkeypatch.setattr(cleanup, 'ROWS_PER_TABLE', 1) + for number in (1, 2): + await insert_record(application, MonitoringMessage, number) + await insert_record(application, MonitoringMessage, 3, timestamp=NOW - dt.timedelta(days=5)) + for number in (1, 2): + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + assert not MediaCache.has_inline_images( + json.loads(await read_record(application, MonitoringMessage, number, 'message_content')) + ) + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + assert await cleanup.cleanup_inline_media(application, CONTEXT, 1) == 1 + assert (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).first() + + +@pytest.mark.asyncio +async def test_event_scan_budget_advances_past_foreign_events_without_reading_payloads(application, monkeypatch): + monkeypatch.setattr(cleanup, 'ROWS_PER_TABLE', 2) + for number in range(1, 5): + await insert_record(application, AgentRunEvent, number, workspace=WORKSPACE_B) + await application.persistence_mgr.execute_async(sa.delete(AgentRun).where(AgentRun.run_id == 'parent-3')) + await insert_record(application, AgentRunEvent, 5) + await insert_record(application, AgentRunEvent, 6, timestamp=NOW) + engine = application.persistence_mgr.get_db_engine() + statements = [] + + def capture_statement(connection, cursor, statement, parameters, context, executemany): + if statement.startswith('SELECT ') and 'FROM agent_run_event' in statement: + statements.append((statement, parameters)) + + sa.event.listen(engine.sync_engine, 'before_cursor_execute', capture_statement) + try: + for expected_id in (2, 4): + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + cursor = ( + await application.persistence_mgr.execute_async( + sa.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == WORKSPACE_A, + WorkspaceMetadata.key == cleanup.CURSOR_PREFIX + 'agent_run_event', + ) + ) + ).scalar_one() + assert json.loads(cursor) == [OLD.isoformat(), expected_id] + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + finally: + sa.event.remove(engine.sync_engine, 'before_cursor_execute', capture_statement) + + assert not MediaCache.has_inline_images(json.loads(await read_record(application, AgentRunEvent, 5, 'data_json'))) + for number in (1, 2, 3, 4, 6): + assert await read_record(application, AgentRunEvent, number, 'data_json') == PAYLOAD + async with engine.connect() as connection: + for statement, parameters in statements: + plan = ' '.join( + row[3] for row in await connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + statement, parameters) + ) + if 'CORRELATED' in plan: + assert 'SEARCH agent_run_event USING INTEGER PRIMARY KEY' in plan, plan + else: + assert 'COVERING INDEX ix_agent_run_event_media_ttl' in plan, plan + assert 'TEMP B-TREE' not in plan, plan + + +@pytest.mark.asyncio +async def test_payload_budget_processes_one_oversized_row_then_yields(application, monkeypatch): + monkeypatch.setattr(cleanup, 'CHARACTERS_PER_TABLE', 1) + await insert_record(application, MonitoringMessage, 1) + await insert_record(application, MonitoringMessage, 2) + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + assert await read_record(application, MonitoringMessage, 2, 'message_content') == PAYLOAD + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + + +@pytest.mark.asyncio +async def test_payload_update_and_cursor_roll_back_together(application, monkeypatch): + await insert_record(application, MonitoringMessage, 1) + execute = application.persistence_mgr.execute_async + + async def fail_checkpoint(statement, *args, **kwargs): + if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'workspace_metadata': + raise RuntimeError('Checkpoint failed') + return await execute(statement, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(application.persistence_mgr, 'execute_async', fail_checkpoint) + with pytest.raises(RuntimeError, match='Checkpoint failed'): + await cleanup.cleanup_inline_media(application, CONTEXT, 30) + assert await read_record(application, MonitoringMessage, 1, 'message_content') == PAYLOAD + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + + +@pytest.mark.asyncio +async def test_unchanged_or_invalid_records_are_not_rewritten_or_revisited(application): + values = [ + 'plain text', + '{invalid base64 JSON', + '{"count": 0, "enabled": false}', + json.dumps({'type': 'Image', 'url': '/api/v1/files/media/existing.png'}), + ] + for number, payload in enumerate(values, 1): + await insert_record(application, MonitoringMessage, number, payload=payload) + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + for number, payload in enumerate(values, 1): + assert await read_record(application, MonitoringMessage, number, 'message_content') == payload + before = (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all() + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + after = (await application.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all() + assert before == after + + +@pytest.mark.asyncio +@pytest.mark.parametrize('model', [MonitoringMessage, AgentRunEvent]) +async def test_concurrent_payload_change_does_not_advance_cursor(application, monkeypatch, model): + field = await insert_record(application, model, 1) + execute = application.persistence_mgr.execute_async + + async def lose_update(statement, *args, **kwargs): + if isinstance(statement, sa.sql.dml.Update) and statement.table.name == model.__tablename__: + return SimpleNamespace(rowcount=0) + return await execute(statement, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(application.persistence_mgr, 'execute_async', lose_update) + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 0 + assert ( + await execute( + sa.select(WorkspaceMetadata.value).where( + WorkspaceMetadata.workspace_uuid == WORKSPACE_A, + WorkspaceMetadata.key == cleanup.CURSOR_PREFIX + model.__tablename__, + ) + ) + ).first() is None + assert await read_record(application, model, 1, field) == PAYLOAD + assert await cleanup.cleanup_inline_media(application, CONTEXT, 30) == 1 + + +@pytest.mark.asyncio +async def test_storage_maintenance_uses_media_ttl_for_inline_records(application): + await insert_record(application, MonitoringMessage, 1, timestamp=NOW - dt.timedelta(days=5)) + application.instance_config.data = {'storage': {'media_cache': {'retention_days': 2}}} + cache = SimpleNamespace(cleanup=AsyncMock(return_value={})) + application.storage_mgr = SimpleNamespace(media_cache=cache) + service = MaintenanceService(application) + service._is_oss_singleton = AsyncMock(return_value=False) + service._cleanup_expired_uploaded_files = AsyncMock(return_value=0) + assert (await service.cleanup_expired_files(CONTEXT))['inline_media_records'] == 1 + cache.cleanup.assert_not_awaited() + + +def test_ttl_indexes_migrate_idempotently_and_support_cursor_queries(): + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes') + engine = sa.create_engine('sqlite:///:memory:') + try: + with engine.begin() as connection: + Base.metadata.create_all(connection) + with Operations.context(MigrationContext.configure(connection)): + migration.downgrade() + migration.upgrade() + migration.upgrade() + for table, name, columns in migration.INDEXES: + indexes = { + index['name']: index['column_names'] for index in sa.inspect(connection).get_indexes(table) + } + assert indexes[name] == columns + orm_index = next(index for index in Base.metadata.tables[table].indexes if index.name == name) + assert [column.name for column in orm_index.columns] == columns + query = ( + sa.select(MonitoringMessage.id, MonitoringMessage.message_content) + .where( + MonitoringMessage.workspace_uuid == WORKSPACE_A, + MonitoringMessage.timestamp < NOW, + sa.tuple_(MonitoringMessage.timestamp, MonitoringMessage.id) > sa.tuple_(OLD, '0'), + ) + .order_by(MonitoringMessage.timestamp, MonitoringMessage.id) + .limit(1) + ) + sql = str(query.compile(dialect=engine.dialect, compile_kwargs={'literal_binds': True})) + plan = ' '.join(str(row) for row in connection.exec_driver_sql('EXPLAIN QUERY PLAN ' + sql)) + assert 'ix_monitoring_messages_media_ttl' in plan + assert 'TEMP B-TREE' not in plan + migration.downgrade() + finally: + engine.dispose() + + +def test_index_migration_allows_historical_schemas_without_monitoring_tables(): + migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0039_execution_lease_indexes') + engine = sa.create_engine('sqlite:///:memory:') + try: + with engine.begin() as connection: + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + migration.downgrade() + assert not sa.inspect(connection).get_table_names() + finally: + engine.dispose() diff --git a/tests/unit_tests/storage/test_media_cache.py b/tests/unit_tests/storage/test_media_cache.py index 054ee725b..5ba502ad3 100644 --- a/tests/unit_tests/storage/test_media_cache.py +++ b/tests/unit_tests/storage/test_media_cache.py @@ -1,5 +1,6 @@ import base64 import datetime +import json import os import tempfile from pathlib import Path @@ -127,6 +128,51 @@ class TestMediaCache: # Should not raise; base64 should be stripped as fallback assert result[0]['base64'] is None + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'image', + [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA==', 'detail': 'low'}}, + {'type': 'image', 'image_url': 'data:image/png;base64,eA=='}, + {'type': 'Image', 'url': 'data:image/png;base64,eA=='}, + ], + ) + async def test_externalize_image_encodings_without_mutating_runtime_input(self, image): + original = json.dumps(image) + result = await self.media_cache.externalize_chain_dump({'nested': [image]}) + saved = result['nested'][0] + assert saved['storage_key'].startswith('media_cache/') + self.mock_provider.save.assert_awaited_once_with(saved['storage_key'], b'x') + assert not MediaCache.has_inline_images(result) + assert json.dumps(image) == original + if image.get('type') == 'image_url': + assert saved['image_url']['detail'] == 'low' + + @pytest.mark.asyncio + @pytest.mark.parametrize('payload', ['invalid!!!', 'https://example.com/image.png']) + async def test_invalid_base64_is_removed_without_saving_or_downloading(self, payload): + result = await self.media_cache.externalize_chain_dump({'type': 'Image', 'base64': payload}) + assert result['base64'] is None + self.mock_provider.save.assert_not_awaited() + + @pytest.mark.asyncio + async def test_url_only_images_do_not_enter_cas(self): + image = {'type': 'Image', 'url': 'https://example.com/image.png'} + assert not MediaCache.has_inline_images(image) + assert await self.media_cache.externalize_chain_dump(image) == image + self.mock_provider.exists.assert_not_awaited() + self.mock_provider.save.assert_not_awaited() + + @pytest.mark.asyncio + async def test_oversized_inline_image_is_removed_before_decode(self): + image = {'type': 'Image', 'base64': 'data:image/png;base64,' + 'A' * 100} + with patch('langbot.pkg.storage.media.DEFAULT_BASE64_MEDIA_LIMIT', 16): + result = await self.media_cache.externalize_chain_dump(image) + assert result['base64'] is None + self.mock_provider.save.assert_not_awaited() + @pytest.mark.asyncio async def test_cleanup_retention_and_max_size(self): with tempfile.TemporaryDirectory() as temp_dir: @@ -197,8 +243,8 @@ class TestMonitoringServiceSanitization: def test_sanitize_oversized_base64_payload(self): svc = MonitoringService.__new__(MonitoringService) small_content = '{"type": "Image", "base64": "data:image/png;base64,tiny"}' - # Should leave small contents untouched - assert svc._sanitize_message_content(small_content) == small_content + # Metadata-only records must not retain even small inline images. + assert json.loads(svc._sanitize_message_content(small_content))['base64'] is None # Large content with base64 data URL huge_b64 = 'A' * 60000 @@ -213,3 +259,21 @@ class TestMonitoringServiceSanitization: sanitized_raw = svc._sanitize_message_content(raw_corrupted) assert '[base64 image omitted]' in sanitized_raw assert multiline_b64 not in sanitized_raw + + @pytest.mark.parametrize( + 'image', + [ + {'type': 'Image', 'base64': 'eA=='}, + {'type': 'Image', 'base64': 'A' * 20000}, + {'type': 'image_base64', 'image_base64': 'eA=='}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,eA=='}}, + ], + ) + def test_redaction_covers_raw_and_provider_images_at_any_size(self, image): + svc = MonitoringService.__new__(MonitoringService) + payload = {'event': {'images': [image]}, 'count': 0, 'enabled': False} + saved = json.loads(svc._sanitize_message_content(json.dumps(payload))) + assert not MediaCache.has_inline_images(saved) + assert saved['count'] == 0 + assert saved['enabled'] is False + assert MediaCache.has_inline_images(payload) diff --git a/tests/unit_tests/storage/test_media_persistence.py b/tests/unit_tests/storage/test_media_persistence.py new file mode 100644 index 000000000..0b1fb9318 --- /dev/null +++ b/tests/unit_tests/storage/test_media_persistence.py @@ -0,0 +1,256 @@ +"""Persist media references without changing live messages or metadata-only policy.""" + +import base64 +import copy +import json +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy as sa + +from langbot.pkg.agent.runner.run_journal import AgentRunJournal +from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore +from langbot.pkg.entity.persistence.agent_run import AgentRunEvent +from langbot.pkg.entity.persistence.event_log import EventLog +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage +from langbot.pkg.entity.persistence.transcript import Transcript +from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper +from langbot.pkg.storage.media import MediaCache +from tests.unit_tests.api.service.test_monitoring_tenancy import ( + WORKSPACE_A, + _context, + _record_message, + service as base_service, # noqa: F401 +) + + +@pytest.fixture +def media_service(base_service): # noqa: F811 + service = base_service + objects = {} + + async def save(key, data): + objects[key] = data + + provider = SimpleNamespace( + exists=AsyncMock(side_effect=lambda key: key in objects), + save=AsyncMock(side_effect=save), + ) + service.ap.logger = logging.getLogger(__name__) + service.ap.storage_mgr = SimpleNamespace(storage_provider=provider) + cache = MediaCache(service.ap, service.ap.storage_mgr) + service.ap.storage_mgr.media_cache = cache + service.ap.monitoring_service = service + return service, objects, cache + + +def image(raw=b'tiny adapter image', *, encoded=True): + payload = base64.b64encode(raw).decode() + return {'type': 'Image', 'base64': f'data:image/png;base64,{payload}' if encoded else payload} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'response_kind', + ['input', 'resp_message_chain', 'resp_messages', 'direct', 'structured', 'structured_dict', 'discarded'], +) +async def test_all_retained_monitoring_messages_use_cas(media_service, response_kind, monkeypatch): + service, objects, _ = media_service + context = _context(WORKSPACE_A) + chain = [image()] + original = copy.deepcopy(chain) + legacy_content = json.dumps(chain) + json_dumps = json.dumps + + def serialize_references(value, *args, **kwargs): + assert not MediaCache.has_inline_images(value), 'Inline images must reach CAS before JSON serialization' + return json_dumps(value, *args, **kwargs) + + monkeypatch.setattr(json, 'dumps', serialize_references) + query = SimpleNamespace( + _execution_context=context, + launcher_type='person', + launcher_id='user', + sender_id='user', + message_chain=SimpleNamespace(model_dump=lambda: chain), + variables={}, + ) + resource = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='pipeline', pipeline_name='Pipeline') + if response_kind == 'input': + await MonitoringHelper.record_query_start(service.ap, query, **resource) + elif response_kind == 'direct': + await _record_message(service, context, legacy_content) + elif response_kind in ('structured', 'structured_dict'): + content = {'chain': chain} if response_kind == 'structured_dict' else chain + await _record_message(service, context, content) + elif response_kind == 'discarded': + from langbot.pkg.platform.botmgr import RuntimeBot + + bot = SimpleNamespace( + ap=service.ap, + execution_context=context, + bot_entity=SimpleNamespace(uuid='same-bot', name='Bot'), + PIPELINE_DISCARD=RuntimeBot.PIPELINE_DISCARD, + PIPELINE_DISCARD_DISPLAY_NAME=RuntimeBot.PIPELINE_DISCARD_DISPLAY_NAME, + logger=SimpleNamespace(error=AsyncMock()), + ) + await RuntimeBot._record_discarded_message( + bot, 'person', 'user', 'user', SimpleNamespace(), query.message_chain + ) + else: + response = SimpleNamespace(model_dump=lambda: chain) + if response_kind == 'resp_message_chain': + query.resp_message_chain = [response] + else: + query.resp_messages = [SimpleNamespace(get_content_platform_message_chain=lambda: response)] + await MonitoringHelper.record_query_response(service.ap, query, **resource) + + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = (await conn.execute(sa.select(MonitoringMessage.message_content))).scalar_one() + saved_chain = json.loads(stored) + saved = (saved_chain['chain'] if isinstance(saved_chain, dict) else saved_chain)[0] + assert saved['base64'] is None + assert saved['url'].startswith('/api/v1/files/media/') + assert objects[saved['storage_key']] == b'tiny adapter image' + assert chain == original + + +@pytest.mark.asyncio +async def test_metadata_only_ingress_discards_images_without_populating_cas(media_service): + service, objects, _ = media_service + payload = {'type': 'message.received', 'message_chain': [image(encoded=False)], 'count': 0} + original = copy.deepcopy(payload) + await service.record_ingress_event( + _context(WORKSPACE_A), + event_id='ingress', + event_type='message.received', + bot_id='same-bot', + bot_name='Bot', + platform='slack', + payload=payload, + ) + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = (await conn.execute(sa.select(EventLog.input_json))).scalar_one() + assert json.loads(stored)['message_chain'][0]['base64'] is None + assert not objects + assert payload == original + + +@pytest.mark.asyncio +async def test_repeated_images_deduplicate_across_messages_and_run_events(media_service): + service, objects, cache = media_service + raw = b'shared image' + payload = image(raw) + await _record_message(service, _context(WORKSPACE_A), json.dumps([payload])) + journal = AgentRunJournal(service.ap) + result = { + 'type': 'message.completed', + 'data': { + 'message': {'content': [payload]}, + 'provider_image': {'type': 'image_base64', 'image_base64': payload['base64']}, + }, + } + original = copy.deepcopy(result) + metadata = {'diagnostic_image': image(b'metadata-only image')} + await journal.append_run_result(result_dict=result, run_id='run', sequence=1, metadata=metadata) + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = (await conn.execute(sa.select(AgentRunEvent.data_json))).scalar_one() + saved_metadata = (await conn.execute(sa.select(AgentRunEvent.metadata_json))).scalar_one() + saved = json.loads(stored) + assert saved['message']['content'][0]['hash'] == cache.hash_bytes(raw) + assert saved['provider_image']['image_base64'] is None + assert saved['provider_image']['image_url']['url'].startswith('/api/v1/files/media/') + assert len(objects) == 1 + service.ap.storage_mgr.storage_provider.save.assert_awaited_once() + assert result == original + assert not MediaCache.has_inline_images(json.loads(saved_metadata)) + assert MediaCache.has_inline_images(metadata) + + +@pytest.mark.asyncio +async def test_cas_failure_does_not_write_inline_payload_to_database(media_service): + service, objects, _ = media_service + service.ap.storage_mgr.storage_provider.save.side_effect = OSError('Disk full') + message_id = await _record_message(service, _context(WORKSPACE_A), json.dumps([image()])) + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = ( + await conn.execute(sa.select(MonitoringMessage.message_content).where(MonitoringMessage.id == message_id)) + ).scalar_one() + assert json.loads(stored)[0]['base64'] is None + assert not objects + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field', ['data', 'metadata']) +async def test_direct_ledger_writes_cannot_bypass_image_persistence(media_service, field): + service, objects, _ = media_service + engine = service.ap.persistence_mgr.get_db_engine() + with pytest.raises(sa.exc.StatementError, match='Inline images cannot be persisted'): + await RunLedgerStore(engine).append_event( + run_id='run', sequence=1, event_type='tool.call.completed', **{field: {'image': image()}} + ) + async with engine.connect() as conn: + assert (await conn.execute(sa.select(sa.func.count()).select_from(AgentRunEvent))).scalar_one() == 0 + assert not objects + + +@pytest.mark.asyncio +@pytest.mark.parametrize('write_kind', ['create', 'update']) +async def test_monitoring_variables_follow_metadata_only_policy(media_service, write_kind): + service, objects, _ = media_service + variables = json.dumps({'preview': image(), 'count': 0}) + context = _context(WORKSPACE_A) + if write_kind == 'create': + message_id = await service.record_message( + context, + bot_id='same-bot', + bot_name='Bot', + pipeline_id='pipeline', + pipeline_name='Pipeline', + message_content='message', + session_id='session', + variables=variables, + ) + else: + message_id = await _record_message(service, context, 'message') + await service.update_message_status(context, message_id, 'success', variables=variables) + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = ( + await conn.execute(sa.select(MonitoringMessage.variables).where(MonitoringMessage.id == message_id)) + ).scalar_one() + assert json.loads(stored) == {'preview': {'type': 'Image', 'base64': None}, 'count': 0} + assert not objects + + +@pytest.mark.asyncio +@pytest.mark.parametrize('role', ['user', 'assistant']) +async def test_transcript_remains_metadata_only_without_creating_cas_objects(media_service, role): + service, objects, _ = media_service + contents = [image(), {'type': 'image_base64', 'image_base64': 'eA=='}] + original = copy.deepcopy(contents) + event = SimpleNamespace( + event_id='event', + conversation_id='conversation', + bot_id='same-bot', + workspace_id=WORKSPACE_A, + thread_id=None, + actor=None, + input=SimpleNamespace(text='image', contents=contents, attachments=[]), + ) + journal = AgentRunJournal(service.ap) + if role == 'user': + await journal.write_user_transcript(event, 'event') + else: + await journal.write_assistant_transcript( + result_dict={'data': {'message': {'role': 'assistant', 'content': contents}}}, + event=event, + run_id='run', + runner_id='runner', + ) + async with service.ap.persistence_mgr.get_db_engine().connect() as conn: + stored = (await conn.execute(sa.select(Transcript.content_json))).scalar_one() + assert not MediaCache.has_inline_images(json.loads(stored)) + assert not objects + assert contents == original diff --git a/tests/unit_tests/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py index 7e90cf43d..98ebdb20d 100644 --- a/tests/unit_tests/storage/test_s3storage.py +++ b/tests/unit_tests/storage/test_s3storage.py @@ -12,8 +12,9 @@ Uses moto library to mock AWS S3 service. from __future__ import annotations import pytest -from unittest.mock import Mock +from unittest.mock import Mock, patch from importlib import import_module +from botocore.exceptions import ClientError def get_s3storage_module(): @@ -57,14 +58,6 @@ def s3_mock(): class TestS3StorageProviderInit: """Tests for S3StorageProvider initialization.""" - def test_init_stores_app_reference(self): - """Test that __init__ stores the Application reference.""" - s3storage = get_s3storage_module() - - mock_app = Mock() - provider = s3storage.S3StorageProvider(mock_app) - assert provider.ap is mock_app - def test_init_s3_client_none(self): """Test that s3_client starts as None.""" s3storage = get_s3storage_module() @@ -102,6 +95,7 @@ class TestS3StorageProviderWithMoto: assert provider.s3_client is not None assert provider.bucket_name == 'test-langbot-storage' mock_app_with_s3_config.logger.info.assert_called() + assert [bucket['Name'] for bucket in s3_mock.list_buckets()['Buckets']] == ['test-langbot-storage'] @pytest.mark.asyncio async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock): @@ -112,11 +106,16 @@ class TestS3StorageProviderWithMoto: s3_mock.create_bucket(Bucket='test-langbot-storage') provider = s3storage.S3StorageProvider(mock_app_with_s3_config) - await provider.initialize() + with ( + patch.object(s3storage.boto3.session.Session, 'client', return_value=s3_mock), + patch.object(s3_mock, 'head_bucket', wraps=s3_mock.head_bucket) as head_bucket, + patch.object(s3_mock, 'create_bucket', wraps=s3_mock.create_bucket) as create_bucket, + ): + await provider.initialize() - assert provider.s3_client is not None - # Bucket creation log should not be called since bucket exists - # Note: moto may still call head_bucket successfully + assert provider.s3_client is s3_mock + head_bucket.assert_called_once_with(Bucket='test-langbot-storage') + create_bucket.assert_not_called() @pytest.mark.asyncio async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock): @@ -232,7 +231,7 @@ class TestS3StorageProviderWithMoto: @pytest.mark.asyncio async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock): - """Test that delete_dir_recursive handles path without trailing slash.""" + """Test that delete_dir_recursive handles a path with a trailing slash.""" s3storage = get_s3storage_module() provider = s3storage.S3StorageProvider(mock_app_with_s3_config) @@ -241,8 +240,7 @@ class TestS3StorageProviderWithMoto: # Save object await provider.save('mydir/file.txt', b'data') - # Delete without trailing slash - await provider.delete_dir_recursive('mydir') + await provider.delete_dir_recursive('mydir/') # Verify deleted assert await provider.exists('mydir/file.txt') is False @@ -325,8 +323,9 @@ class TestS3StorageProviderErrorHandling: provider = s3storage.S3StorageProvider(mock_app) await provider.initialize() - with pytest.raises(Exception): + with pytest.raises(ClientError) as exc_info: await provider.load('nonexistent.txt') + assert exc_info.value.response['Error']['Code'] == 'NoSuchKey' @pytest.mark.asyncio async def test_size_nonexistent_raises_error(self, s3_mock): @@ -350,5 +349,6 @@ class TestS3StorageProviderErrorHandling: provider = s3storage.S3StorageProvider(mock_app) await provider.initialize() - with pytest.raises(Exception): + with pytest.raises(ClientError) as exc_info: await provider.size('nonexistent.txt') + assert exc_info.value.response['Error']['Code'] == '404' diff --git a/tests/unit_tests/storage/test_storage_manager.py b/tests/unit_tests/storage/test_storage_manager.py index e17c317bd..ab7c1eb4a 100644 --- a/tests/unit_tests/storage/test_storage_manager.py +++ b/tests/unit_tests/storage/test_storage_manager.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, AsyncMock, patch from langbot.pkg.storage.mgr import StorageMgr from langbot.pkg.storage.providers.localstorage import LocalStorageProvider -from langbot.pkg.storage.providers.s3storage import S3StorageProvider class TestStorageMgr: @@ -21,77 +20,6 @@ class TestStorageMgr: storage_mgr = StorageMgr(mock_app) assert storage_mgr.ap == mock_app - @pytest.mark.asyncio - async def test_initialize_default_local(self): - """Should use local storage by default.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - 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() - - @pytest.mark.asyncio - async def test_initialize_with_explicit_local(self): - """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.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) - - @pytest.mark.asyncio - async def test_initialize_with_s3(self): - """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.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, S3StorageProvider) - - @pytest.mark.asyncio - async def test_initialize_invalid_type_defaults_to_local(self): - """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.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock): - await storage_mgr.initialize() - assert isinstance(storage_mgr.storage_provider, LocalStorageProvider) - - @pytest.mark.asyncio - async def test_initialize_calls_provider_initialize(self): - """Should call the provider's initialize method.""" - mock_app = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - mock_app.logger = Mock() - - storage_mgr = StorageMgr(mock_app) - - with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init: - await storage_mgr.initialize() - mock_init.assert_called_once() - @pytest.mark.asyncio async def test_shutdown_delegates_to_active_provider(self): storage_mgr = StorageMgr(Mock()) diff --git a/tests/unit_tests/survey/test_survey_manager.py b/tests/unit_tests/survey/test_survey_manager.py index da67e8219..aac76e3ed 100644 --- a/tests/unit_tests/survey/test_survey_manager.py +++ b/tests/unit_tests/survey/test_survey_manager.py @@ -234,6 +234,12 @@ class TestTriggerEvent: await manager.trigger_event('new_event') assert 'new_event' in manager._triggered_events + statements = mock_app.persistence_mgr.execute_async.await_args_list + assert len(statements) == 2 + assert statements[-1].args[0].compile().params == { + 'key': 'survey_triggered_events', + 'value': '["new_event"]', + } class TestRecordBotResponseSuccess: @@ -255,8 +261,9 @@ class TestRecordBotResponseSuccess: 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 + statements = mock_app.persistence_mgr.execute_async.await_args_list + assert len(statements) == 2 + assert statements[-1].args[0].compile().params == {'key': 'survey_bot_response_count', 'value': '1'} @pytest.mark.asyncio async def test_fires_milestone_event_at_threshold(self): diff --git a/tests/unit_tests/telemetry/test_batch_ack.py b/tests/unit_tests/telemetry/test_batch_ack.py new file mode 100644 index 000000000..0aeff5448 --- /dev/null +++ b/tests/unit_tests/telemetry/test_batch_ack.py @@ -0,0 +1,29 @@ +from unittest.mock import Mock + +import httpx +import pytest + +from langbot.pkg.telemetry.telemetry import TelemetryManager + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'ack,expected', + [ + ({'stored': 1, 'rejected': 0}, True), + ({'stored': 0, 'rejected': 1}, False), + ({'stored': 1, 'rejected': 1}, False), + ({'stored': 0, 'rejected': 0}, False), + ({}, False), + ({'stored': True, 'rejected': 0}, False), + ], +) +async def test_batch_requires_complete_ack(ack, expected): + app = Mock() + manager = TelemetryManager(app) + manager.telemetry_config = {'url': 'https://telemetry.test'} + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={'code': 0, 'data': ack})) + ) as client: + manager._client = client + assert await manager.send({'records': [{'event_id': 'a' * 163}]}) is expected diff --git a/tests/unit_tests/telemetry/test_cloud_sampling.py b/tests/unit_tests/telemetry/test_cloud_sampling.py index b1a604489..ef4de5e2e 100644 --- a/tests/unit_tests/telemetry/test_cloud_sampling.py +++ b/tests/unit_tests/telemetry/test_cloud_sampling.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from langbot.pkg.telemetry.execution import ExecutionCounters +from langbot.pkg.telemetry.trace import TraceState from langbot.pkg.utils import constants @@ -8,8 +9,10 @@ def test_cloud_counts_are_not_sampled(monkeypatch): monkeypatch.setattr(constants, 'edition', 'cloud') counters = ExecutionCounters(SimpleNamespace(telemetry_config={'execution_trace': 'sampled'})) assert counters.trace_mode() == 'all' - state = SimpleNamespace(failure_reason='', stages=[], synthetic=False, trace_id='00000001') - assert counters._trace_emitted(state) + state = TraceState('00000001') + counters.configure(state) + assert state.emit_nodes is True + assert counters._chain_emitted(state) is True def test_community_retains_sampling(monkeypatch): diff --git a/tests/unit_tests/telemetry/test_features.py b/tests/unit_tests/telemetry/test_features.py deleted file mode 100644 index 6e19d0828..000000000 --- a/tests/unit_tests/telemetry/test_features.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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_pipeline_lane.py b/tests/unit_tests/telemetry/test_pipeline_lane.py index f091f2bb8..80e635436 100644 --- a/tests/unit_tests/telemetry/test_pipeline_lane.py +++ b/tests/unit_tests/telemetry/test_pipeline_lane.py @@ -1,4 +1,4 @@ -"""The Pipeline lane must land its failure on the owning execution trace.""" +"""The Pipeline lane must land its nodes on one v2 execution chain.""" from __future__ import annotations @@ -43,7 +43,7 @@ def build_pipeline(pipelinemgr, ap): return pipeline -def test_lane_failure_marks_the_owned_trace_without_any_prior_stage(): +def test_lane_failure_marks_the_owned_chain_without_any_prior_stage(): pipelinemgr, execution = get_modules() manager = FakeManager() counters = execution.ExecutionCounters(manager) @@ -54,15 +54,16 @@ def test_lane_failure_marks_the_owned_trace_without_any_prior_stage(): with execution.ingress(ap, 'pipeline_done', CONTEXT, execution_id='query-7'): pipeline._record_lane_failure(query, 'sandbox exited 127') - assert len(counters.records) == 1 - record = counters.records[0] - assert record['query_id'] == 'query-7' - assert record['error'] == 'sandbox exited 127' - assert record['features']['trace']['outcome'] == 'failed' - observations = record['features']['observations'] - assert [(row['family'], row['operation'], row['outcome']) for row in observations] == [ - ('runner', 'execute', 'failed') - ] + rows = [record for record in counters.records if record['event_type'] == 'execution_node'] + assert len(rows) == 1 + node = rows[0] + assert node['event_id'] == 'query-7' + assert node['error'] == 'sandbox exited 127' + assert (node['family'], node['operation'], node['outcome']) == ('runner', 'execute', 'failed') + chain = [record for record in counters.records if record['event_type'] == 'execution_chain'][0] + assert chain['event_id'] == 'query-7' + assert chain['outcome'] == 'failed' + assert chain['error'] == 'sandbox exited 127' def test_lane_failure_does_not_duplicate_the_runner_node_the_orchestrator_recorded(): @@ -87,13 +88,13 @@ def test_lane_failure_does_not_duplicate_the_runner_node_the_orchestrator_record ) pipeline._record_lane_failure(query, 'No authorized model for local-agent') - observations = [ - (row['family'], row['operation'], row['outcome']) - for record in counters.records - for row in record['features']['observations'] + rows = [record for record in counters.records if record['event_type'] == 'execution_node'] + assert [(record['family'], record['operation'], record['outcome']) for record in rows] == [ + ('runner', 'execute', 'failed') ] - assert observations == [('runner', 'execute', 'failed')] - assert counters.records[-1]['features']['trace']['outcome'] == 'failed' + chain = [record for record in counters.records if record['event_type'] == 'execution_chain'][-1] + assert chain['outcome'] == 'failed' + assert chain['error'] == 'No authorized model for local-agent' def build_counters(): @@ -112,17 +113,28 @@ def build_lane(pipelinemgr, ap): def build_query(**overrides): - query = types.SimpleNamespace(query_uuid='exec-lane', query_id=1, pipeline_config={}, variables={}) + query = types.SimpleNamespace( + query_uuid='exec-lane', + query_id=1, + pipeline_config={}, + variables={}, + adapter=None, + launcher_type=types.SimpleNamespace(value='private'), + launcher_id='user', + ) for key, value in overrides.items(): setattr(query, key, value) return query +def lane_nodes(counters): + return [record for record in counters.records if record['event_type'] == 'execution_node'] + + def lane_shapes(counters): return [ - (row['family'], row['operation'], row.get('node'), row.get('parent')) - for record in counters.records - for row in record['features']['observations'] + (record['family'], record['operation'], record['node_id'], record['parent_node_id']) + for record in lane_nodes(counters) ] @@ -138,7 +150,7 @@ async def stop_counters(counters): counters.task = None -def test_lane_step_owns_the_apis_and_runner_it_calls(): +async def test_lane_step_owns_the_apis_and_runner_it_calls(): pipelinemgr, execution = get_modules() trace = get_trace() counters, ap = build_counters() @@ -148,8 +160,13 @@ def test_lane_step_owns_the_apis_and_runner_it_calls(): async def fake_process(_query): execution.record( - ap, CONTEXT, family='platform_api', operation='send_message', - adapter='AiocqhttpAdapter', mode='pipeline', outcome='success', + ap, + CONTEXT, + family='platform_api', + operation='send_message', + adapter='AiocqhttpAdapter', + mode='pipeline', + outcome='success', ) with trace.stage_scope() as run_node: seen['run'] = run_node @@ -158,8 +175,13 @@ def test_lane_step_owns_the_apis_and_runner_it_calls(): ) # A reply issued after the runner still belongs to the lane. execution.record( - ap, CONTEXT, family='platform_api', operation='reply_message', - adapter='AiocqhttpAdapter', mode='pipeline', outcome='success', + ap, + CONTEXT, + family='platform_api', + operation='reply_message', + adapter='AiocqhttpAdapter', + mode='pipeline', + outcome='success', ) lane._process_query = fake_process @@ -168,21 +190,28 @@ def test_lane_step_owns_the_apis_and_runner_it_calls(): await lane.process_query(query) await stop_counters(counters) - asyncio.run(scenario()) + await scenario() shapes = lane_shapes(counters) - lane_node = shapes[-1][2] - assert lane_node == 'n1' - assert shapes == [ - ('platform_api', 'send_message', None, lane_node), - ('runner', 'execute', seen['run'], lane_node), - ('platform_api', 'reply_message', None, lane_node), - # The lane step is recorded last; a standalone lane is a root. - ('pipeline', 'run', lane_node, None), + # The synthetic inbound event roots the chain; the lane step hangs under it. + assert [shape[0:2] for shape in shapes] == [ + ('platform_event', 'message.received'), + ('platform_api', 'send_message'), + ('runner', 'execute'), + ('platform_api', 'reply_message'), + ('pipeline', 'run'), ] + event_node = shapes[0][2] + lane_node = shapes[-1][2] + assert shapes[0][3] == '' + assert shapes[1][3] == lane_node + assert shapes[2] == ('runner', 'execute', seen['run'], lane_node) + assert shapes[3][3] == lane_node + # The lane step is recorded last and nests under the synthetic event node. + assert shapes[4] == ('pipeline', 'run', lane_node, event_node) -def test_lane_node_nests_under_the_platform_route_that_called_it(): +async def test_lane_node_nests_under_the_platform_route_that_called_it(): pipelinemgr, execution = get_modules() trace = get_trace() counters, ap = build_counters() @@ -197,35 +226,47 @@ def test_lane_node_nests_under_the_platform_route_that_called_it(): lane._process_query = fake_process async def scenario(): - # A platform event owns the trace; the route step wraps the Pipeline lane. + # A platform event owns the chain; the route step wraps the Pipeline lane. with execution.ingress(ap, 'event_done', CONTEXT, execution_id='exec-routed'): with trace.stage_scope() as event_node: execution.record( - ap, CONTEXT, family='platform_event', operation='message.received', - adapter='AiocqhttpAdapter', outcome='success', node=event_node, + ap, + CONTEXT, + family='platform_event', + operation='message.received', + adapter='AiocqhttpAdapter', + outcome='success', + node=event_node, ) with trace.stage_scope() as route_node: execution.record( - ap, CONTEXT, family='event_route', operation='message.received', - adapter='AiocqhttpAdapter', outcome='success', node=route_node, + ap, + CONTEXT, + family='event_route', + operation='message.received', + adapter='AiocqhttpAdapter', + outcome='success', + node=route_node, ) await lane.process_query(query) await stop_counters(counters) return event_node, route_node - event_node, route_node = asyncio.run(scenario()) + event_node, route_node = await scenario() - assert (event_node, route_node) == ('n1', 'n2') - assert lane_shapes(counters) == [ - ('platform_event', 'message.received', event_node, None), - ('event_route', 'message.received', route_node, event_node), - # The API is called by the lane, so it hangs on the lane node. - ('platform_api', 'send_message', None, 'n3'), - ('pipeline', 'run', 'n3', route_node), - ] + assert event_node != route_node + shapes = lane_shapes(counters) + assert shapes[0] == ('platform_event', 'message.received', event_node, '') + assert shapes[1] == ('event_route', 'message.received', route_node, event_node) + lane_node = shapes[-1][2] + # The API is called by the lane, so it hangs on the lane node. + assert shapes[2][0:2] == ('platform_api', 'send_message') + assert shapes[2][3] == lane_node and shapes[2][2] != lane_node + # The lane nests under the route step that called it. + assert shapes[3] == ('pipeline', 'run', lane_node, route_node) -def test_broken_lane_records_a_failed_step_with_its_reason(): +async def test_broken_lane_records_a_failed_step_with_its_reason(): pipelinemgr, execution = get_modules() counters, ap = build_counters() lane = build_lane(pipelinemgr, ap) @@ -242,16 +283,19 @@ def test_broken_lane_records_a_failed_step_with_its_reason(): await stop_counters(counters) with pytest.raises(RuntimeError, match='lane exploded'): - asyncio.run(scenario()) + await scenario() - family, operation, node, parent = lane_shapes(counters)[-1] - row = counters.records[0]['features']['observations'][-1] - assert (family, operation, node, parent) == ('pipeline', 'run', 'n1', None) + shapes = lane_shapes(counters) + family, operation, node, parent = shapes[-1] + row = lane_nodes(counters)[-1] + # The lane step nests under the synthetic inbound event that roots the chain. + assert (family, operation) == ('pipeline', 'run') + assert shapes[0] == ('platform_event', 'message.received', parent, '') assert row['outcome'] == 'failed' assert row['error'] == 'lane exploded' -def test_lane_reported_failure_marks_the_step_failed(): +async def test_lane_reported_failure_marks_the_step_failed(): pipelinemgr, execution = get_modules() counters, ap = build_counters() lane = build_lane(pipelinemgr, ap) @@ -266,7 +310,7 @@ def test_lane_reported_failure_marks_the_step_failed(): await lane.process_query(query) await stop_counters(counters) - asyncio.run(scenario()) + await scenario() - row = counters.records[0]['features']['observations'][-1] + row = lane_nodes(counters)[-1] assert (row['family'], row['operation'], row['outcome']) == ('pipeline', 'run', 'failed') diff --git a/tests/unit_tests/telemetry/test_telemetry.py b/tests/unit_tests/telemetry/test_telemetry.py index 004009a68..230710b31 100644 --- a/tests/unit_tests/telemetry/test_telemetry.py +++ b/tests/unit_tests/telemetry/test_telemetry.py @@ -6,7 +6,6 @@ Tests cover: - Early return conditions (disabled, empty config, no server) - URL construction (with actual URL verification) - HTTP request success/failure scenarios -- Source code bug: send_tasks should be instance variable """ from __future__ import annotations @@ -84,7 +83,9 @@ class TestTelemetrySendEarlyReturn: manager.telemetry_config = {} # Should return without making HTTP calls - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() # No HTTP client should be created, no logs should be written mock_app.logger.debug.assert_not_called() @@ -100,7 +101,9 @@ class TestTelemetrySendEarlyReturn: manager = telemetry.TelemetryManager(mock_app) manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'} - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() mock_app.logger.debug.assert_not_called() @@ -114,7 +117,9 @@ class TestTelemetrySendEarlyReturn: manager = telemetry.TelemetryManager(mock_app) manager.telemetry_config = {'url': ''} - await manager.send({'query_id': 'test'}) + with patch.object(httpx, 'AsyncClient') as client: + await manager.send({'query_id': 'test'}) + client.assert_not_called() mock_app.logger.debug.assert_not_called() @@ -628,54 +633,3 @@ class TestAuthenticatedWorkspaceReporter: assert mock_client.post.call_args.kwargs['headers'] == { 'Authorization': 'Bearer refreshed-workspace-owner-token' } - - -class TestStartSendTask: - """Tests for start_send_task() method.""" - - @pytest.mark.asyncio - async def test_start_send_task_creates_task(self): - """Test that start_send_task creates an asyncio task.""" - telemetry = get_telemetry_module() - mock_app = Mock() - mock_app.logger = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - - manager = telemetry.TelemetryManager(mock_app) - manager.telemetry_config = {} - - await manager.start_send_task({'query_id': 'test'}) - - # Task should be added to send_tasks list - assert len(manager.send_tasks) >= 1 - - # Clean up the task - for task in manager.send_tasks: - if not task.done(): - task.cancel() - manager.send_tasks.clear() - - @pytest.mark.asyncio - async def test_start_send_task_multiple_tasks(self): - """Test that multiple tasks are tracked.""" - telemetry = get_telemetry_module() - mock_app = Mock() - mock_app.logger = Mock() - mock_app.instance_config = Mock() - mock_app.instance_config.data = {} - - manager = telemetry.TelemetryManager(mock_app) - manager.telemetry_config = {} - - await manager.start_send_task({'query_id': 'test1'}) - await manager.start_send_task({'query_id': 'test2'}) - await manager.start_send_task({'query_id': 'test3'}) - - assert len(manager.send_tasks) >= 3 - - # Clean up - for task in manager.send_tasks: - if not task.done(): - task.cancel() - manager.send_tasks.clear() diff --git a/tests/unit_tests/telemetry/test_trace.py b/tests/unit_tests/telemetry/test_trace.py index 283cdac65..cf7e63701 100644 --- a/tests/unit_tests/telemetry/test_trace.py +++ b/tests/unit_tests/telemetry/test_trace.py @@ -1,4 +1,10 @@ -"""Unit tests for per-execution execution traces (pkg/telemetry/trace.py + execution.py).""" +"""Unit tests for per-execution execution chains (pkg/telemetry/trace.py + execution.py). + +One execution is one chain of independent records: one ``execution_node`` per +workflow node, emitted the moment the node completes, plus exactly one +``execution_chain`` when the execution closes. Every record of a chain carries +the same ``event_id``. +""" from __future__ import annotations @@ -31,8 +37,21 @@ class FakeManager: self.sent.append(payload) return True - def start_send_task(self, payload: dict) -> None: + +class RejectingManager(FakeManager): + """Stand-in whose sender refuses every batch until told to accept them.""" + + def __init__(self, config=None): + super().__init__(config) + self.rejecting = True + self.attempts = 0 + + async def send(self, payload: dict) -> bool: + self.attempts += 1 + if self.rejecting: + return False self.sent.append(payload) + return True def make_counters(config=None): @@ -56,6 +75,14 @@ def records_sent(manager): return [record for payload in manager.sent for record in payload.get('records', [])] +def nodes(counters): + return [record for record in counters.records if record.get('event_type') == 'execution_node'] + + +def chains(counters): + return [record for record in counters.records if record.get('event_type') == 'execution_chain'] + + CONTEXT = types.SimpleNamespace(instance_uuid='instance-1', workspace_uuid='workspace-1') STAGE = { @@ -66,6 +93,35 @@ STAGE = { 'outcome': 'success', } +NODE_KEYS = { + 'event_type', + 'schema', + 'event_id', + 'instance_id', + 'workspace_uuid', + 'version', + 'edition', + 'timestamp', + 'debug', + 'sample', + 'node_id', + 'parent_node_id', + 'root', + 'seq', + 'family', + 'operation', + 'mode', + 'adapter', + 'runner', + 'outcome', + 'error', + 'route_ref', + 'run_id', + 'synthetic', + 'started_at', + 'ended_at', +} + class TestTraceIdentity: def test_nested_bind_reuses_the_in_flight_trace(self): @@ -93,13 +149,12 @@ class TestTraceIdentity: # A repeated unbind of the same binding must never raise. assert trace.unbind_root(outer) is True - def test_trace_id_is_the_execution_id(self): + def test_event_id_is_the_execution_id(self): trace, _ = get_modules() binding = trace.bind('exec-1234') try: assert binding.created is True - assert binding.state.trace_id == 'exec-1234' - assert binding.state.execution_id == 'exec-1234' + assert binding.state.event_id == 'exec-1234' finally: trace.unbind_root(binding) @@ -109,19 +164,20 @@ class TestTraceIdentity: inner = trace.bind('query-9') try: assert inner.created is False - assert inner.state.trace_id == 'event-1' + assert inner.state.event_id == 'event-1' finally: trace.unbind_root(inner) trace.unbind_root(outer) def test_route_and_run_are_only_pinned_inside_the_block(self): trace, _ = get_modules() + _, counters = make_counters(trace_config()) binding = trace.bind() token = trace.set_run('run-1') try: with trace.scope(route_ref='agent:agent-1'): - inside = trace.current() - inside.append( + counters.record( + CONTEXT, family='runner', operation='execute', mode='agent', @@ -130,8 +186,8 @@ class TestTraceIdentity: outcome='success', synthetic=False, ) - outside = trace.current() - outside.append( + counters.record( + CONTEXT, family='platform_api', operation='send_message', mode='agent', @@ -143,7 +199,7 @@ class TestTraceIdentity: finally: trace.reset_run(token) trace.unbind_root(binding) - assert [(s['route_ref'], s['run_id']) for s in binding.state.stages] == [ + assert [(row['route_ref'], row['run_id']) for row in nodes(counters)] == [ ('agent:agent-1', 'run-1'), ('', 'run-1'), ] @@ -152,65 +208,42 @@ class TestTraceIdentity: class TestTraceRecording: def test_stage_carries_sequence_identity_and_timestamps(self): trace, _ = get_modules() - _, counters = make_counters() + _, counters = make_counters(trace_config()) binding = trace.bind() try: counters.record(CONTEXT, **STAGE) counters.record(CONTEXT, **{**STAGE, 'family': 'runner', 'operation': 'execute', 'mode': 'pipeline'}) + counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - stages = binding.state.stages - assert [stage['seq'] for stage in stages] == [0, 1] - assert stages[0]['first_seen'] == stages[0]['last_seen'] - assert binding.state.identity == {'instance_id': 'instance-1', 'workspace_uuid': 'workspace-1'} - assert binding.state.outcome() == 'success' - - def test_stage_buffer_is_bounded_and_counts_overflow(self): - trace, _ = get_modules() - _, counters = make_counters() - binding = trace.bind() - try: - for _ in range(trace.MAX_STAGES + 5): - counters.record(CONTEXT, **STAGE) - finally: - trace.unbind_root(binding) - assert len(binding.state.stages) == trace.MAX_STAGES - assert binding.state.dropped_stages == 5 + rows = nodes(counters) + assert [row['seq'] for row in rows] == [0, 1] + # A plain observation starts and ends at the same instant. + assert rows[0]['started_at'] == rows[0]['ended_at'] + assert {(row['instance_id'], row['workspace_uuid']) for row in rows} == {('instance-1', 'workspace-1')} + chain = chains(counters)[0] + assert chain['outcome'] == 'success' + assert chain['stage_count'] == 2 def test_oversized_identifier_never_becomes_a_stage(self): trace, _ = get_modules() - _, counters = make_counters() + _, counters = make_counters(trace_config()) binding = trace.bind() try: - counters.record(CONTEXT, **{**STAGE, 'operation': 'x' * 200}) + counters.record(CONTEXT, **{**STAGE, 'operation': 'x' * 513}) counters.record(CONTEXT, **{**STAGE, 'family': 'not-a-family'}) finally: trace.unbind_root(binding) - assert binding.state.stages == [] - - def test_trace_limit_abandons_further_traces(self): - trace, execution = get_modules() - _, counters = make_counters() - bindings = [] - for _ in range(execution.MAX_TRACES + 1): - binding = trace.bind() - bindings.append(binding) - counters.record(CONTEXT, **STAGE) - trace.unbind_root(binding) - assert trace.current() is None - assert len(counters.traces) == execution.MAX_TRACES - assert counters.dropped == 1 - assert bindings[-1].state.abandoned is True - assert bindings[-1].state.stages == [] + assert nodes(counters) == [] def test_observation_without_an_owning_execution_is_dropped(self): - trace, _ = get_modules() + _, _ = get_modules() _, counters = make_counters() - # Clustering is gone: an observation that belongs to no execution is - # discarded instead of being aggregated into a synthetic bucket. + # An observation that belongs to no execution is discarded instead of + # being aggregated into a synthetic bucket. counters.record(CONTEXT, **STAGE) assert counters.records == [] - assert counters.traces == {} + assert counters.registry == {} class TestTraceNodeScope: @@ -238,11 +271,13 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - step_row, api_row = binding.state.stages - assert step_row['node'] == step - assert 'parent' not in step_row # a root step carries no parent - assert api_row['parent'] == step - assert 'node' not in api_row # observations are not step nodes + step_row, api_row = nodes(counters) + assert step_row['node_id'] == step + assert step_row['parent_node_id'] == '' # a root step carries no parent + assert step_row['root'] is True + assert api_row['parent_node_id'] == step + assert api_row['root'] is False + assert api_row['node_id'] != step # observations are not step nodes def test_nested_step_records_its_enclosing_step_as_parent(self): trace, _ = get_modules() @@ -257,11 +292,11 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - outer_row, inner_row, api_row = binding.state.stages - assert (outer_row['node'], outer_row.get('parent')) == (outer, None) + outer_row, inner_row, api_row = nodes(counters) + assert (outer_row['node_id'], outer_row['parent_node_id']) == (outer, '') # The step's own record sits under its enclosing step, never under itself. - assert (inner_row['node'], inner_row.get('parent')) == (inner, outer) - assert api_row.get('parent') == inner and 'node' not in api_row + assert (inner_row['node_id'], inner_row['parent_node_id']) == (inner, outer) + assert api_row['parent_node_id'] == inner and api_row['node_id'] != inner def test_sequential_steps_get_distinct_ids_and_roots_have_no_parent(self): trace, _ = get_modules() @@ -275,13 +310,16 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - assert (first, second) == ('n1', 'n2') - assert [row['node'] for row in binding.state.stages] == [first, second] - assert all('parent' not in row for row in binding.state.stages) + assert first and second and first != second + rows = nodes(counters) + assert [row['node_id'] for row in rows] == [first, second] + assert [row['seq'] for row in rows] == [0, 1] + assert rows[0]['root'] is True + assert all(row['parent_node_id'] == '' for row in rows) - def test_cross_task_observation_attaches_to_the_open_step(self): + def test_cross_task_observation_joins_the_owning_execution(self): trace, execution = get_modules() - _, counters = make_counters() + _, counters = make_counters(trace_config()) binding = execution.bind_trace(make_ap(counters), 'exec-cross') try: with trace.stage_scope() as lane: @@ -290,7 +328,7 @@ class TestTraceNodeScope: ) # A plugin/RPC observation arrives on a task that never had the # trace context; it is resolved through the execution registry and - # must still hang off the step the owning task has open. + # still lands on the owning chain. foreign = contextvars.Context() foreign.run(execution.set_execution_id, 'exec-cross') foreign.run( @@ -304,13 +342,59 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - rows = binding.state.stages - assert rows[0]['node'] == lane and 'parent' not in rows[0] - assert rows[1].get('parent') == lane and 'node' not in rows[1] + lane_row, foreign_row = nodes(counters) + assert lane_row['node_id'] == lane and lane_row['parent_node_id'] == '' and lane_row['root'] is True + # The cross-task node joins the same execution and hangs off the step the + # chain had open - the step that caused the work - instead of mirroring + # any task's own stack. + assert foreign_row['event_id'] == 'exec-cross' + assert foreign_row['parent_node_id'] == lane + assert foreign_row['root'] is False + + def test_cross_task_observation_attaches_to_the_innermost_open_step(self): + trace, execution = get_modules() + _, counters = make_counters(trace_config()) + binding = execution.bind_trace(make_ap(counters), 'exec-nested') + try: + with trace.stage_scope() as outer: + counters.record(CONTEXT, **{**STAGE, 'node': outer}) + with trace.stage_scope() as inner: + counters.record(CONTEXT, **{**STAGE, 'node': inner}) + foreign = contextvars.Context() + foreign.run(execution.set_execution_id, 'exec-nested') + foreign.run(counters.record, CONTEXT, **{**STAGE, 'family': 'tool', 'operation': 'exec'}) + finally: + trace.unbind_root(binding) + + outer_row, inner_row, tool_row = nodes(counters) + assert outer_row['parent_node_id'] == '' + assert inner_row['parent_node_id'] == outer + # The lane that caused the cross-task work is the innermost one open. + assert tool_row['parent_node_id'] == inner + + def test_cross_task_observation_after_the_lane_closed_is_a_root(self): + trace, execution = get_modules() + _, counters = make_counters(trace_config()) + binding = execution.bind_trace(make_ap(counters), 'exec-late') + try: + with trace.stage_scope() as lane: + counters.record(CONTEXT, **{**STAGE, 'node': lane}) + # A late callback arrives after the owning step returned: nothing is + # open anymore, so the node is a root of the chain rather than a + # child of a finished step. + foreign = contextvars.Context() + foreign.run(execution.set_execution_id, 'exec-late') + foreign.run(counters.record, CONTEXT, family='platform_api', operation='reply', mode='pipeline') + finally: + trace.unbind_root(binding) + + lane_row, late_row = nodes(counters) + assert late_row['parent_node_id'] == '' and late_row['root'] is False + assert lane_row['root'] is True def test_cross_task_observation_without_an_open_step_has_no_parent(self): trace, execution = get_modules() - _, counters = make_counters() + _, counters = make_counters(trace_config()) binding = execution.bind_trace(make_ap(counters), 'exec-bare') try: foreign = contextvars.Context() @@ -326,8 +410,8 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - row = binding.state.stages[0] - assert 'node' not in row and 'parent' not in row + row = nodes(counters)[0] + assert row['parent_node_id'] == '' and row['root'] is True def test_scope_outside_any_trace_is_inert(self): trace, _ = get_modules() @@ -335,9 +419,9 @@ class TestTraceNodeScope: assert node == '' assert trace.current_parent() == '' - def test_records_outside_any_scope_serialize_without_node_keys(self): - trace, execution = get_modules() - manager, counters = make_counters(trace_config()) + def test_records_outside_any_scope_are_root_nodes(self): + trace, _ = get_modules() + _, counters = make_counters(trace_config()) binding = trace.bind('exec-legacy') try: counters.record(CONTEXT, **STAGE) @@ -345,29 +429,16 @@ class TestTraceNodeScope: finally: trace.unbind_root(binding) - stage = binding.state.stages[0] - assert set(stage) == { - 'family', - 'operation', - 'mode', - 'adapter', - 'runner', - 'outcome', - 'synthetic', - 'seq', - 'route_ref', - 'run_id', - 'first_seen', - 'last_seen', - 'error', - } - observation = counters.records[0]['features']['observations'][0] - assert 'node' not in json.dumps(observation, sort_keys=True) - assert 'parent' not in json.dumps(observation, sort_keys=True) + node = nodes(counters)[0] + assert set(node) == NODE_KEYS + assert node['node_id'] and node['parent_node_id'] == '' and node['root'] is True + assert node['seq'] == 0 + # Node placement lives on node records only; the chain carries none. + assert 'node_id' not in json.dumps(chains(counters)[0], sort_keys=True) class TestTraceDelivery: - def test_close_buffers_one_record_per_trace(self): + def test_close_emits_one_terminal_chain_record_per_execution(self): trace, _ = get_modules() manager, counters = make_counters(trace_config()) binding = trace.bind('exec-1') @@ -383,49 +454,55 @@ class TestTraceDelivery: # Closing buffers; nothing is sent before the flush cadence. assert manager.sent == [] - assert len(counters.records) == 1 - assert counters.traces == {} - - record = counters.records[0] - assert record['event_type'] == 'feature_execution' - assert record['query_id'] == 'exec-1' + assert len(nodes(counters)) == 2 + chain_rows = chains(counters) + assert len(chain_rows) == 1 + record = chain_rows[0] + assert record['schema'] == 2 + assert record['event_id'] == 'exec-1' assert record['instance_id'] == 'instance-1' assert record['workspace_uuid'] == 'workspace-1' - assert record['trusted'] is True - features = record['features'] - assert features['schema'] == 1 - assert 'count' not in json.dumps(record) - assert [row['seq'] for row in features['observations']] == [0, 1] - assert [row['trace_id'] for row in features['observations']] == ['exec-1'] * 2 - assert features['trace'] == { - 'closed_by': 'event_done', - 'started_at': binding.state.started_at, - 'ended_at': features['trace']['ended_at'], - 'route_ref': '', - 'run_id': '', - 'outcome': 'success', - 'dropped_stages': 0, - } + assert record['closed_by'] == 'event_done' + assert record['outcome'] == 'success' + assert record['stage_count'] == 2 + assert isinstance(record['duration_ms'], int) and record['duration_ms'] >= 0 + assert 'query_id' not in record and 'features' not in record def test_sampling_decision_is_deterministic_and_keeps_failures(self): trace, _ = get_modules() _, counters = make_counters(trace_config(execution_trace='sampled', execution_trace_sample=2)) - binding = trace.bind() - try: - binding.state.trace_id = '00000001-0000-4000-8000-000000000000' - counters.record(CONTEXT, **STAGE) - assert counters._trace_emitted(binding.state) is False - binding.state.trace_id = '00000000-0000-4000-8000-000000000000' - assert counters._trace_emitted(binding.state) is True - binding.state.trace_id = '00000001-0000-4000-8000-000000000001' - counters.record(CONTEXT, **{**STAGE, 'outcome': 'timeout'}) - assert counters._trace_emitted(binding.state) is True - finally: - trace.unbind_root(binding) + in_id = next(i for i in (f'sample-{n}' for n in range(64)) if counters._sampling_decision(i)[2]) + out_id = next(i for i in (f'sample-{n}' for n in range(64)) if not counters._sampling_decision(i)[2]) + # Same id, same answer: the decision is a pure function of the identity. + assert counters._sampling_decision(in_id) == counters._sampling_decision(in_id) - def test_failures_mode_keeps_failed_and_debug_traces_only(self): + excluded = trace.bind(out_id) + counters.record(CONTEXT, **STAGE) + assert excluded.state.emit_nodes is False + assert counters._chain_emitted(excluded.state) is False + counters.close_trace(excluded.state, 'event_done') + trace.unbind_root(excluded) + assert counters.records == [] + + included = trace.bind(in_id) + counters.record(CONTEXT, **STAGE) + assert included.state.emit_nodes is True + counters.close_trace(included.state, 'event_done') + trace.unbind_root(included) + assert [record['event_type'] for record in counters.records] == ['execution_node', 'execution_chain'] + + counters.records.clear() + broken = trace.bind(out_id) + counters.record(CONTEXT, **{**STAGE, 'outcome': 'timeout'}) + assert counters._chain_emitted(broken.state) is True + counters.close_trace(broken.state, 'event_done') + trace.unbind_root(broken) + assert [record['event_type'] for record in counters.records] == ['execution_chain'] + assert counters.records[0]['outcome'] == 'timeout' + + def test_failures_mode_keeps_failed_and_debug_chains_only(self): trace, _ = get_modules() - _, counters = make_counters(trace_config(execution_trace='failures', execution_trace_sample=1000000)) + _, counters = make_counters(trace_config(execution_trace='failures')) failed = trace.bind('exec-failed') counters.record(CONTEXT, **{**STAGE, 'outcome': 'failed'}) @@ -433,7 +510,8 @@ class TestTraceDelivery: trace.unbind_root(failed) debug = trace.bind('exec-debug') - counters.record(CONTEXT, **{**STAGE, 'synthetic': True}) + counters.configure(debug.state, debug=True) + counters.record(CONTEXT, **STAGE) counters.close_trace(debug.state, 'event_done') trace.unbind_root(debug) @@ -442,9 +520,12 @@ class TestTraceDelivery: counters.close_trace(sampled_out.state, 'event_done') trace.unbind_root(sampled_out) - assert [record['query_id'] for record in counters.records] == ['exec-failed', 'exec-debug'] - assert counters.records[0]['features']['observations'][0]['outcome'] == 'failed' - assert counters.records[1]['features']['observations'][0]['synthetic'] is True + assert [record['event_id'] for record in chains(counters)] == ['exec-failed', 'exec-debug'] + assert chains(counters)[0]['outcome'] == 'failed' + # Its node was sampled out, but the broken chain still explains itself. + assert chains(counters)[0]['dropped_nodes'] == 1 + assert chains(counters)[1]['debug'] is True + assert [record['event_id'] for record in nodes(counters)] == ['exec-debug'] def test_off_mode_and_opt_out_buffer_nothing(self): trace, _ = get_modules() @@ -469,24 +550,9 @@ class TestTraceDelivery: trace.unbind_root(binding) assert disabled_manager.sent == [] assert disabled.records == [] - assert disabled.traces == {} + assert disabled.registry == {} - def test_ttl_sweep_closes_stale_trace(self): - trace, _ = get_modules() - manager, counters = make_counters(trace_config()) - binding = trace.bind('exec-ttl') - try: - counters.record(CONTEXT, **{**STAGE, 'outcome': 'failed'}) - counters.trace_deadlines[binding.state.trace_id] = 0 - counters._sweep_traces(time.monotonic()) - finally: - trace.unbind_root(binding) - assert counters.records[0]['features']['trace']['closed_by'] == 'ttl' - assert counters.traces == {} - assert counters.trace_deadlines == {} - assert manager.sent == [] - - async def test_shutdown_drops_open_traces(self): + async def test_shutdown_does_not_claim_an_unfinished_execution(self): trace, _ = get_modules() manager, counters = make_counters(trace_config()) binding = trace.bind('exec-open') @@ -495,26 +561,12 @@ class TestTraceDelivery: await counters.shutdown() finally: trace.unbind_root(binding) - # Shutdown must not claim a trace that never completed. - assert records_sent(manager) == [] + # The already-emitted node is flushed, but shutdown never fabricates a + # terminal chain for an execution that never completed. + assert [record['event_type'] for record in records_sent(manager)] == ['execution_node'] assert counters.records == [] - assert counters.traces == {} + assert chains(counters) == [] assert counters.registry == {} - assert counters.dropped_traces == 1 - - async def test_loop_sweeps_and_flushes_expired_trace(self): - trace, _ = get_modules() - manager, counters = make_counters(trace_config(telemetry_flush_seconds=1)) - binding = trace.bind('exec-ttl-loop') - try: - counters.record(CONTEXT, **{**STAGE, 'outcome': 'failed'}) - counters.trace_deadlines[binding.state.trace_id] = 0 - await asyncio.sleep(1.3) - assert [record['features']['trace']['closed_by'] for record in records_sent(manager)] == ['ttl'] - assert counters.traces == {} - finally: - await counters.shutdown() - trace.unbind_root(binding) async def test_flush_sends_one_batch_of_complete_records(self): _, execution = get_modules() @@ -523,14 +575,20 @@ class TestTraceDelivery: for index in range(2): with execution.ingress(ap, 'event_done', CONTEXT, execution_id=f'exec-{index}'): counters.record(CONTEXT, **STAGE) - assert len(counters.records) == 2 + assert len(counters.records) == 4 await counters.flush() assert len(manager.sent) == 1 payload = manager.sent[0] assert set(payload) == {'records'} - assert [record['query_id'] for record in payload['records']] == ['exec-0', 'exec-1'] - assert 'count' not in json.dumps(payload) + assert [record['event_type'] for record in payload['records']] == [ + 'execution_node', + 'execution_chain', + 'execution_node', + 'execution_chain', + ] + assert [record['event_id'] for record in payload['records']] == ['exec-0', 'exec-0', 'exec-1', 'exec-1'] + await counters.shutdown() async def test_flush_interval_is_honoured(self): _, execution = get_modules() @@ -541,7 +599,9 @@ class TestTraceDelivery: assert counters.flush_seconds() == 1 assert manager.sent == [] await asyncio.sleep(1.3) - assert [record['query_id'] for record in records_sent(manager)] == ['exec-timed'] + sent = records_sent(manager) + assert [record['event_type'] for record in sent] == ['execution_node', 'execution_chain'] + assert {record['event_id'] for record in sent} == {'exec-timed'} await counters.shutdown() def test_flush_interval_config_is_bounded(self): @@ -563,11 +623,10 @@ class TestTraceDelivery: for index in range(5): binding = trace.bind(f'exec-{index}') counters.record(CONTEXT, **STAGE) - counters.close_trace(binding.state, 'event_done') trace.unbind_root(binding) finally: execution.MAX_BUFFERED_RECORDS = original - assert [record['query_id'] for record in counters.records] == ['exec-2', 'exec-3', 'exec-4'] + assert [record['event_id'] for record in nodes(counters)] == ['exec-2', 'exec-3', 'exec-4'] assert counters.dropped == 2 @@ -579,9 +638,9 @@ class TestIngress: with execution.ingress(ap, 'event_done', execution_id='exec-outer'): with execution.ingress(ap, 'pipeline_done', execution_id='exec-inner'): counters.record(CONTEXT, **{**STAGE, 'outcome': 'failed'}) - assert len(counters.records) == 1 - assert counters.records[0]['query_id'] == 'exec-outer' - assert counters.records[0]['features']['trace']['closed_by'] == 'event_done' + assert [record['event_type'] for record in counters.records] == ['execution_node', 'execution_chain'] + assert all(record['event_id'] == 'exec-outer' for record in counters.records) + assert chains(counters)[0]['closed_by'] == 'event_done' assert manager.sent == [] def test_ingress_without_telemetry_is_a_no_op(self): @@ -602,7 +661,7 @@ class TestIngress: class TestCrossTaskAttribution: - def test_registry_joins_stages_from_a_task_without_a_trace(self): + def test_registry_joins_nodes_from_a_task_without_a_trace(self): trace, execution = get_modules() _, counters = make_counters(trace_config()) ap = make_ap(counters) @@ -615,23 +674,24 @@ class TestCrossTaskAttribution: counters.record( CONTEXT, **{**STAGE, 'family': 'platform_api', 'operation': 'send_message'}, execution_id='run-1' ) - assert len(binding.state.stages) == 1 - assert binding.state.stages[0]['operation'] == 'send_message' + rows = nodes(counters) + assert len(rows) == 1 + assert rows[0]['operation'] == 'send_message' and rows[0]['event_id'] == 'run-1' counters.close_trace(binding.state, 'event_done') - assert counters.records[0]['query_id'] == 'run-1' + assert chains(counters)[0]['event_id'] == 'run-1' def test_registry_aliases_a_nested_execution(self): _, execution = get_modules() _, counters = make_counters(trace_config()) ap = make_ap(counters) - with execution.ingress(ap, 'event_done', CONTEXT, execution_id='event-1') as binding: + with execution.ingress(ap, 'event_done', CONTEXT, execution_id='event-1'): with execution.ingress(ap, 'pipeline_done', CONTEXT, execution_id='query-1'): pass assert set(counters.registry) == {'event-1', 'query-1'} counters.record(CONTEXT, **STAGE, execution_id='query-1') - assert counters.records[0]['query_id'] == 'event-1' - assert len(binding.state.stages) == 1 + rows = nodes(counters) + assert len(rows) == 1 and rows[0]['event_id'] == 'event-1' def test_contextvar_execution_id_is_used_when_no_kwarg(self): trace, execution = get_modules() @@ -644,7 +704,8 @@ class TestCrossTaskAttribution: counters.record(CONTEXT, **STAGE) finally: execution.reset_execution_id(token) - assert len(binding.state.stages) == 1 + rows = nodes(counters) + assert len(rows) == 1 and rows[0]['event_id'] == 'run-2' def test_registry_alias_is_swept_when_never_closed(self): trace, execution = get_modules() @@ -667,8 +728,10 @@ class TestTraceDispatch: with execution.ingress(ap, 'event_done', CONTEXT, execution_id='exec-async'): counters.record(CONTEXT, **STAGE) await counters.flush() - assert [record['query_id'] for record in records_sent(manager)] == ['exec-async'] - assert manager.sent[0]['records'][0]['event_type'] == 'feature_execution' + sent = records_sent(manager) + assert [record['event_type'] for record in sent] == ['execution_node', 'execution_chain'] + assert all(record['event_id'] == 'exec-async' for record in sent) + await counters.shutdown() class AsyncSendManager(FakeManager): @@ -689,9 +752,9 @@ class TestTraceFailureDetail: counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - record = counters.records[0] - assert record['error'] == 'boom: sandbox exited 127' - assert record['features']['observations'][0]['error'] == 'boom: sandbox exited 127' + assert nodes(counters)[0]['error'] == 'boom: sandbox exited 127' + # The detail lives on the node; the chain carries the terminal outcome. + assert chains(counters)[0]['outcome'] == 'failed' def test_successful_stage_reports_no_error(self): trace, _ = get_modules() @@ -702,9 +765,8 @@ class TestTraceFailureDetail: counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - record = counters.records[0] - assert record['error'] == '' - assert record['features']['observations'][0]['error'] == '' + assert nodes(counters)[0]['error'] == '' + assert chains(counters)[0]['error'] == '' def test_error_detail_is_bounded_and_single_line(self): trace, _ = get_modules() @@ -715,8 +777,8 @@ class TestTraceFailureDetail: counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - detail = counters.records[0]['error'] - assert len(detail) <= 400 + detail = chains(counters)[0]['error'] + assert len(detail) <= 200 assert '\n' not in detail and '\t' not in detail @@ -733,10 +795,10 @@ class TestIngressFailureDetail: except RuntimeError: raised = True assert raised is True - record = counters.records[0] - assert record['error'].startswith('RuntimeError: dispatch exploded') - assert record['features']['trace']['outcome'] == 'failed' - assert record['features']['trace']['closed_by'] == 'event_done' + chain = chains(counters)[0] + assert chain['error'].startswith('RuntimeError: dispatch exploded') + assert chain['outcome'] == 'failed' + assert chain['closed_by'] == 'event_done' def test_successful_ingress_still_reports_success(self): _, execution = get_modules() @@ -744,27 +806,29 @@ class TestIngressFailureDetail: ap = make_ap(counters) with execution.ingress(ap, 'event_done', execution_id='exec-fine'): counters.record(CONTEXT, **STAGE) - record = counters.records[0] - assert record['error'] == '' - assert record['features']['trace']['outcome'] == 'success' + chain = chains(counters)[0] + assert chain['error'] == '' + assert chain['outcome'] == 'success' class TestFailureOnlyTrace: def test_failure_without_stages_still_uploads(self): _, execution = get_modules() - # sampled-out config: only failures are guaranteed to be kept - _, counters = make_counters({'url': 'https://space.example.test', 'execution_trace_sample': 1}) + # failures mode: nodes are not reported, but a broken chain always is. + _, counters = make_counters({'url': 'https://space.example.test', 'execution_trace': 'failures'}) ap = make_ap(counters) try: with execution.ingress(ap, 'pipeline_done', CONTEXT, execution_id='exec-empty'): raise RuntimeError('pipeline exploded before any stage') except RuntimeError: pass - assert len(counters.records) == 1 - record = counters.records[0] + rows = chains(counters) + assert len(rows) == 1 + record = rows[0] assert record['error'].startswith('RuntimeError: pipeline exploded') - assert record['features']['trace']['outcome'] == 'failed' - assert record['features']['observations'] == [] + assert record['outcome'] == 'failed' + assert record['stage_count'] == 0 + assert nodes(counters) == [] def test_cancelled_and_timeout_chains_report_their_outcome(self): trace, _ = get_modules() @@ -776,15 +840,14 @@ class TestFailureOnlyTrace: counters.close_trace(binding.state, 'runner_done') finally: trace.unbind_root(binding) - assert len(counters.records) == 1 - record = counters.records.pop(0) - assert record['query_id'] == f'exec-{outcome}' - assert record['features']['trace']['outcome'] == outcome - assert record['error'] == reason + record = chains(counters)[-1] + assert record['event_id'] == f'exec-{outcome}' + assert record['outcome'] == outcome + assert nodes(counters)[-1]['error'] == reason class TestLegacyRecordFields: - def test_record_carries_legacy_top_level_columns(self): + def test_chain_carries_execution_scoped_columns(self): trace, _ = get_modules() _, counters = make_counters(trace_config()) binding = trace.bind('exec-legacy') @@ -800,14 +863,13 @@ class TestLegacyRecordFields: counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - record = counters.records[0] + record = chains(counters)[0] assert record['model_name'] == 'gpt-4o' assert record['pipeline_plugins'] == ['plugin:a'] assert record['runner_category'] == 'cloud' assert record['adapter'] == 'AiocqhttpAdapter' assert record['runner'] == 'runner-1' assert record['duration_ms'] >= 1000 - assert record['timestamp'] == record['features']['trace']['ended_at'] def test_legacy_string_fields_are_never_null(self): trace, _ = get_modules() @@ -818,9 +880,135 @@ class TestLegacyRecordFields: counters.close_trace(binding.state, 'event_done') finally: trace.unbind_root(binding) - record = counters.records[0] + record = chains(counters)[0] assert record['model_name'] == '' assert record['runner'] == '' assert record['runner_category'] == '' assert isinstance(record['duration_ms'], int) and record['duration_ms'] >= 0 assert record['pipeline_plugins'] is None + + +class TestNoTraceTtl: + def test_nodes_more_than_two_minutes_apart_still_emit(self): + trace, _ = get_modules() + _, counters = make_counters(trace_config()) + binding = trace.bind('exec-long') + try: + counters.record(CONTEXT, **STAGE) + # A gap far beyond the old 120s TTL must not sweep the chain: there is + # no local trace deadline any more, so both nodes and the terminal + # chain are still emitted. + binding.state.started_at = (datetime.now(timezone.utc) - timedelta(seconds=200)).isoformat() + counters.record(CONTEXT, **{**STAGE, 'operation': 'still.alive'}) + counters.close_trace(binding.state, 'event_done') + finally: + trace.unbind_root(binding) + rows = nodes(counters) + assert [record['seq'] for record in rows] == [0, 1] + assert [record['operation'] for record in rows] == ['message.received', 'still.alive'] + chain = chains(counters)[0] + assert chain['stage_count'] == 2 + assert chain['duration_ms'] >= 120_000 + + +class TestFlushDelivery: + async def test_rejected_batch_stays_buffered_and_is_retried(self): + trace, execution = get_modules() + manager = RejectingManager(trace_config()) + counters = execution.ExecutionCounters(manager) + binding = trace.bind('exec-retry') + try: + counters.record(CONTEXT, **STAGE) + assert await counters.flush() is False + assert manager.attempts == execution.MAX_FLUSH_ATTEMPTS + # The record was never lost: it is still buffered and unsent. + assert len(counters.records) == 1 and manager.sent == [] + manager.rejecting = False + assert await counters.flush() is True + finally: + trace.unbind_root(binding) + assert counters.records == [] + assert [record['event_id'] for record in records_sent(manager)] == ['exec-retry'] + await counters.shutdown() + + async def test_health_reports_buffered_sent_and_dropped(self): + trace, execution = get_modules() + _, counters = make_counters(trace_config()) + ap = make_ap(counters) + binding = execution.bind_trace(ap, 'exec-health') + try: + counters.record(CONTEXT, **STAGE) + health = counters.health() + assert health['records_buffered'] == 1 + assert health['records_sent'] == 0 + assert health['records_dropped'] == 0 + assert health['executions_in_flight'] == 1 + await counters.flush() + health = counters.health() + assert health['records_buffered'] == 0 + assert health['records_sent'] == 1 + finally: + trace.unbind_root(binding) + + # Overflowing the outbound buffer is surfaced as dropped records. + original = execution.MAX_BUFFERED_RECORDS + execution.MAX_BUFFERED_RECORDS = 1 + try: + for index in range(3): + inner = trace.bind(f'exec-drop-{index}') + counters.record(CONTEXT, **STAGE) + trace.unbind_root(inner) + finally: + execution.MAX_BUFFERED_RECORDS = original + health = counters.health() + assert health['records_dropped'] == 2 + assert health['records_buffered'] == 1 + await counters.shutdown() + + +class TestSiblingTaskIsolation: + async def test_sibling_stage_scopes_do_not_corrupt_parent_links(self): + trace, execution = get_modules() + _, counters = make_counters(trace_config()) + ap = make_ap(counters) + opened = {} + + async def work(name): + with trace.stage_scope() as node: + opened[name] = node + counters.record(CONTEXT, **{**STAGE, 'operation': name, 'node': node}) + + async def scenario(): + with execution.ingress(ap, 'event_done', CONTEXT, execution_id='exec-siblings'): + await asyncio.gather(work('alpha'), work('beta')) + snapshot = list(counters.records) + await counters.shutdown() + return snapshot + + records = await scenario() + rows = {record['operation']: record for record in records if record['event_type'] == 'execution_node'} + assert opened['alpha'] != opened['beta'] + # Each sibling's step is its own root: neither becomes the other's parent. + assert rows['alpha']['node_id'] == opened['alpha'] + assert rows['beta']['node_id'] == opened['beta'] + assert rows['alpha']['parent_node_id'] == '' + assert rows['beta']['parent_node_id'] == '' + assert [record['event_type'] for record in records].count('execution_chain') == 1 + + +class TestSamplingShape: + def test_platform_prefixed_event_id_is_emitted_at_default_config(self): + trace, _ = get_modules() + _, counters = make_counters() # no execution_trace override -> default mode + assert counters.trace_mode() == 'all' + # The identity is hashed, not parsed: a platform-prefixed id samples the + # same way any other id shape does. + assert counters._sampling_decision('platform:bot-1:message-1') == ('all', 1, True) + binding = trace.bind('platform:bot-1:message-1') + try: + counters.record(CONTEXT, **STAGE) + counters.close_trace(binding.state, 'event_done') + finally: + trace.unbind_root(binding) + assert [record['event_type'] for record in counters.records] == ['execution_node', 'execution_chain'] + assert all(record['event_id'] == 'platform:bot-1:message-1' for record in counters.records) diff --git a/tests/unit_tests/test_backend_i18n.py b/tests/unit_tests/test_backend_i18n.py new file mode 100644 index 000000000..a43f4113d --- /dev/null +++ b/tests/unit_tests/test_backend_i18n.py @@ -0,0 +1,107 @@ +"""Runtime language isolation, catalog completeness, and Pipeline overrides.""" + +import asyncio +import json +import string +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from langbot.pkg import i18n +from langbot.pkg.i18n.adapters import adapter_t, localized_adapter +from langbot.pkg.provider.modelmgr.errors import RequesterError + + +def test_catalogs_have_matching_keys_and_placeholders(): + folder = Path(i18n.__file__).parent / 'locales' + catalogs = { + locale: json.loads((folder / f'{locale}.json').read_text(encoding='utf-8')) + for locale in i18n.SUPPORTED_LANGUAGES + } + english = catalogs['en_US'] + formatter = string.Formatter() + + def fields(text): + return {name for _, name, _, _ in formatter.parse(text) if name is not None} + + for locale, catalog in catalogs.items(): + assert catalog.keys() == english.keys(), locale + for key, text in catalog.items(): + assert text.strip(), (locale, key) + assert fields(text) == fields(key), (locale, key) + + +@pytest.mark.asyncio +async def test_concurrent_requests_and_children_keep_their_language(): + async def render(locale): + with i18n.language_scope(locale): + + async def child(): + await asyncio.sleep(0) + return str(RequesterError(i18n.t('Request timed out'))) + + return await asyncio.create_task(child()) + + assert await asyncio.gather(render('zh_Hans'), render('ja_JP'), render('en_US')) == [ + '模型请求失败: 请求超时', + 'モデルへのリクエストに失敗しました: リクエストがタイムアウトしました', + 'Model request failed: Request timed out', + ] + assert i18n.language.get() == 'en_US' + with pytest.raises(RuntimeError), i18n.language_scope('zh_Hans'): + raise RuntimeError('interrupted') + assert i18n.language.get() == 'en_US' + + +def test_pipeline_overrides_inherit_language_and_preserve_literal_braces(): + query = SimpleNamespace(pipeline_config={'output': {'misc': {}}}) + with i18n.language_scope('zh_Hans'): + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。' + query.pipeline_config['output']['misc']['failure-hint'] = 'Request failed.' + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == '请求失败。' + query.pipeline_config['output']['misc']['failure-hint'] = 'My custom notice {literal}' + assert i18n.pipeline_notice(query, 'failure-hint', 'Request failed.') == 'My custom notice {literal}' + query.pipeline_config['output']['misc']['tool-call-hint'] = 'Using {tools}; keep {other}' + assert i18n.pipeline_notice(query, 'tool-call-hint', 'Calling {tools}...', tools='search') == ( + 'Using search; keep {other}' + ) + + +def test_diagnostics_are_never_parsed_or_rewritten(): + diagnostic = 'upstream: {"error": "some provider detail"}' + with i18n.language_scope('zh_Hans'): + assert i18n.t(diagnostic) == diagnostic + assert i18n.error_text('permission_denied', 'Missing Workspace permission: workspace.update') == ( + '没有执行此操作的权限' + ) + assert i18n.t('Please do not replace model not found in user content') == ( + 'Please do not replace model not found in user content' + ) + + +@pytest.mark.asyncio +async def test_adapter_callbacks_use_their_owner_not_the_callers_language(): + app = SimpleNamespace(_message_language_cache={'bot-workspace': ('ja_JP', float('inf'))}) + + class Adapter: + logger = SimpleNamespace(ap=app, execution_context=SimpleNamespace(workspace_uuid='bot-workspace')) + + @localized_adapter + async def reply(self): + return i18n.t('Submitted') + + with i18n.language_scope('zh_Hans'): + assert await Adapter().reply() == '送信済み' + assert adapter_t(Adapter(), 'Submitted') == '送信済み' + assert i18n.language.get() == 'zh_Hans' + + +def test_lark_card_uses_selected_language(): + from langbot.pkg.platform.adapters.lark.stream_card import build_card + + with i18n.language_scope('ja_JP'): + card = json.dumps(build_card('reasonanswer'), ensure_ascii=False) + assert '思考過程' in card + assert 'AI による生成コンテンツ' in card + assert 'Thinking' not in card diff --git a/tests/unit_tests/test_execution_lease.py b/tests/unit_tests/test_execution_lease.py new file mode 100644 index 000000000..2a851eec7 --- /dev/null +++ b/tests/unit_tests/test_execution_lease.py @@ -0,0 +1,54 @@ +import datetime + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.entity.persistence.agent_run import AgentRun +from langbot.pkg.entity.persistence.monitoring import MonitoringMessage, MonitoringError +from langbot.pkg.entity.persistence import workspace, user # noqa: F401 +from langbot.pkg.utils.execution_lease import reconcile_execution_leases + + +@pytest.mark.asyncio +async def test_host_lease_reaping_preserves_live_and_external_work(): + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + now = datetime.datetime(2026, 10, 10) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + for name, owner, expiry, status, queue in ( + ('dead', 'dead-host', -1, 'running', None), + ('other-tenant', 'dead-host', -1, 'running', None), + ('ours', 'live-host', 30, 'running', None), + ('other', 'other-host', 30, 'running', None), + ('done', 'dead-host', -1, 'completed', None), + ('legacy', None, None, 'running', None), + ('remote', 'dead-host', -1, 'running', 'remote'), + ): + await conn.execute(sa.insert(AgentRun).values( + run_id=name, runner_id='runner', workspace_id='other-workspace' if name == 'other-tenant' else 'workspace', status=status, + queue_name=queue, execution_owner_id=owner, + execution_lease_expires_at=now+datetime.timedelta(seconds=expiry) if expiry else None, + )) + await conn.execute(sa.insert(MonitoringMessage).values( + id='pipeline', workspace_uuid='workspace', timestamp=now, + bot_id='bot', bot_name='bot', pipeline_id='pipeline', pipeline_name='pipeline', + message_content='hello', session_id='session', status='pending', level='info', + execution_owner_id='dead-host', execution_lease_expires_at=now-datetime.timedelta(seconds=1), + )) + assert await reconcile_execution_leases(engine, 'workspace', now=now, owner_id='live-host') == 2 + assert await reconcile_execution_leases(engine, 'workspace', now=now, owner_id='live-host') == 0 + async with engine.connect() as conn: + rows = {r['run_id']: r for r in (await conn.execute(sa.select(AgentRun))).mappings()} + assert rows['dead']['status'] == 'failed' + assert rows['dead']['status_reason'] + assert rows['dead']['finished_at'] == now + assert rows['ours']['execution_lease_expires_at'] == now+datetime.timedelta(seconds=120) + for key in ('ours', 'other', 'legacy', 'remote', 'other-tenant'): + assert rows[key]['status'] == 'running' + assert rows['done']['status'] == 'completed' + assert (await conn.execute(sa.select(MonitoringMessage.status))).scalar_one() == 'error' + assert (await conn.execute(sa.select(sa.func.count()).select_from(MonitoringError))).scalar_one() == 1 + await engine.dispose() + diff --git a/tests/unit_tests/test_matrix_relogin.py b/tests/unit_tests/test_matrix_relogin.py index 03ec63ff3..f4aa8778c 100644 --- a/tests/unit_tests/test_matrix_relogin.py +++ b/tests/unit_tests/test_matrix_relogin.py @@ -1,61 +1,89 @@ +"""Regression tests for the production Matrix ``!relogin`` command.""" + from __future__ import annotations -"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``). +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, call -The old code placed ``logout_cmd = ...`` on a line after an unconditional -``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id`` -branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned -on the configured path, so any bridge with both ``login_command`` and -``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above -the ``if`` so it runs for configured bridges. +import pytest -These tests replicate the loop's control flow with a minimal fake bridge so they -run without the Matrix SDK / langbot_plugin dependency. -""" +from langbot.pkg.platform.sources import matrix -class FakeBridge: - def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None): - self.user_id = user_id - self.login_command = login_command - self.logout_command = logout_command - self.dm_room_id = dm_room_id +@pytest.fixture +def bridge(): + bridge = matrix.BridgeState('@u:example.org', 'login', '', '', '') + bridge.dm_room_id = '!bridge:example.org' + bridge.logged_in = True + return bridge -def _relogin_commands(bridges: list[FakeBridge]) -> list[str]: - """Return the logout commands the fixed loop would send for each bridge.""" - commands: list[str] = [] - for bridge in bridges: - if not bridge.login_command or not bridge.dm_room_id: - continue - # Use configured logout command, fallback to deriving from login command. - logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout') - commands.append(logout_cmd) - return commands +@pytest.fixture +def adapter(monkeypatch): + adapter = matrix.MatrixAdapter.model_construct( + client=SimpleNamespace(room_send=AsyncMock()), + logger=None, + ) + monkeypatch.setattr(matrix.asyncio, 'sleep', AsyncMock()) + monkeypatch.setattr(matrix.MatrixAdapter, '_restart_bridge_login', Mock()) + return adapter -def test_configured_bridge_produces_logout_command_without_error() -> None: - bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')] - # Old code raised UnboundLocalError here; the fix must return the derived - # logout command (no configured logout_command -> derive from login). - assert _relogin_commands(bridges) == ['logout'] +async def test_configured_bridge_produces_logout_command_without_error(adapter, bridge) -> None: + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + assert adapter.client.room_send.await_count == 2 + assert adapter.client.room_send.await_args_list[0] == call( + room_id='!bridge:example.org', + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': 'logout'}, + ) + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False -def test_configured_logout_command_is_used_verbatim() -> None: - bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')] - assert _relogin_commands(bridges) == ['leave'] +async def test_configured_logout_command_is_used_verbatim(adapter, bridge) -> None: + bridge.logout_command = 'leave' + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + assert adapter.client.room_send.await_count == 2 + assert adapter.client.room_send.await_args_list[0] == call( + room_id='!bridge:example.org', + message_type='m.room.message', + content={'msgtype': 'm.text', 'body': 'leave'}, + ) + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False -def test_skipped_bridge_is_ignored() -> None: - # Missing dm_room_id -> skipped, no command emitted. - bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)] - assert _relogin_commands(bridges) == [] +async def test_skipped_bridge_is_ignored(adapter, bridge) -> None: + bridge.dm_room_id = None + adapter._bridges = [bridge] + + await adapter._handle_relogin_command('!admin:example.org') + + adapter.client.room_send.assert_awaited_once() + assert adapter.client.room_send.await_args.kwargs['room_id'] == '!admin:example.org' + adapter._restart_bridge_login.assert_not_called() + assert bridge.logged_in is True -def test_relogin_never_raises_for_mixed_configurations() -> None: - bridges = [ - FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command - FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'), - FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id +async def test_relogin_never_raises_for_mixed_configurations(adapter, bridge) -> None: + missing_login = matrix.BridgeState('@skip:example.org', '', '', '', '') + missing_login.dm_room_id = '!skip:example.org' + missing_room = matrix.BridgeState('@skip2:example.org', 'login', '', '', '') + adapter._bridges = [missing_login, bridge, missing_room] + + await adapter._handle_relogin_command('!admin:example.org') + + assert [sent.kwargs['room_id'] for sent in adapter.client.room_send.await_args_list] == [ + '!bridge:example.org', + '!admin:example.org', ] - assert _relogin_commands(bridges) == ['logout'] + assert adapter.client.room_send.await_args_list[0].kwargs['content']['body'] == 'logout' + adapter._restart_bridge_login.assert_called_once_with(bridge) + assert bridge.logged_in is False diff --git a/tests/unit_tests/test_skill_repository.py b/tests/unit_tests/test_skill_repository.py new file mode 100644 index 000000000..053303af4 --- /dev/null +++ b/tests/unit_tests/test_skill_repository.py @@ -0,0 +1,299 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.skill.repository import SkillRepository, SkillRevisionConflictError + + +_CONTEXT = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, +) + + +async def _binding(workspace_uuid, *, expected_generation): + return SimpleNamespace( + instance_uuid=_CONTEXT.instance_uuid, + workspace_uuid=workspace_uuid, + placement_generation=expected_generation, + ) + + +def _repository(tmp_path) -> SkillRepository: + app = SimpleNamespace( + workspace_service=SimpleNamespace( + get_execution_binding=_binding, + ), + instance_config=SimpleNamespace( + data={ + 'skills': {'root': str(tmp_path / 'skill-store')}, + 'box': { + 'enabled': False, + 'local': { + 'host_root': str(tmp_path / 'box'), + }, + }, + } + ), + ) + return SkillRepository(app) + + +def test_repository_prefers_standalone_skill_root(tmp_path): + repository = _repository(tmp_path) + + assert repository._store.root == str((tmp_path / 'skill-store').resolve()) + + +def test_repository_locks_are_workspace_scoped(tmp_path): + repository = _repository(tmp_path) + + first = repository._workspace_lock('workspace-a') + assert repository._workspace_lock('workspace-a') is first + assert repository._workspace_lock('workspace-b') is not first + + +def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path): + app = SimpleNamespace( + workspace_service=SimpleNamespace(get_execution_binding=_binding), + instance_config=SimpleNamespace( + data={ + 'box': { + 'local': { + 'host_root': str(tmp_path / 'box'), + 'skills_root': 'legacy-skills', + } + } + } + ), + ) + + repository = SkillRepository(app) + + assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve()) + + +@pytest.mark.asyncio +async def test_repository_crud_and_reads_do_not_require_box(tmp_path): + repository = _repository(tmp_path) + + created = await repository.create_skill( + _CONTEXT, + { + 'name': 'docs-only', + 'display_name': 'Docs only', + 'description': 'Read-only guidance', + 'instructions': 'Read references/guide.md.', + }, + ) + await repository.write_skill_file( + _CONTEXT, + 'docs-only', + 'references/guide.md', + '# Guide\n\nNo execution needed.', + base_revision=created['revision'], + ) + + skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True) + assert skill is not None + assert skill['revision'].startswith('sha256:') + assert [item['name'] for item in await repository.list_skills(_CONTEXT)] == ['docs-only'] + + listed = await repository.list_skill_resources( + _CONTEXT, + 'docs-only', + 'references', + expected_revision=skill['revision'], + ) + assert listed['entries'][0]['path'] == 'references/guide.md' + assert listed['entries'][0]['mime_type'] == 'text/markdown' + + resource = await repository.read_skill_resource( + _CONTEXT, + 'docs-only', + 'references/guide.md', + expected_revision=skill['revision'], + ) + assert resource['content'].startswith('# Guide') + assert resource['revision'] == skill['revision'] + + +@pytest.mark.asyncio +async def test_repository_rejects_traversal_and_conflicting_update(tmp_path): + repository = _repository(tmp_path) + created = await repository.create_skill( + _CONTEXT, + {'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'}, + ) + first = await repository.write_skill_file( + _CONTEXT, + 'safe', + 'reference.md', + 'first', + base_revision=created['revision'], + ) + skill = await repository.get_skill(_CONTEXT, 'safe', snapshot=True) + assert skill is not None + + with pytest.raises(ValueError, match='stay within'): + await repository.read_skill_resource(_CONTEXT, 'safe', '../secret.txt') + + second = await repository.write_skill_file( + _CONTEXT, + 'safe', + 'reference.md', + 'second', + base_revision=first['revision'], + ) + pinned = await repository.read_skill_resource( + _CONTEXT, + 'safe', + 'reference.md', + expected_revision=skill['revision'], + ) + assert pinned['content'] == 'first' + with pytest.raises(SkillRevisionConflictError, match='changed since'): + await repository.update_skill( + _CONTEXT, + 'safe', + {'instructions': 'stale'}, + base_revision=first['revision'], + ) + assert second['revision'] != first['revision'] + + +@pytest.mark.asyncio +async def test_repository_scopes_skills_by_workspace(tmp_path): + repository = _repository(tmp_path) + other_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-b', + placement_generation=1, + ) + + await repository.create_skill(_CONTEXT, {'name': 'private', 'instructions': 'A'}) + + assert [skill['name'] for skill in await repository.list_skills(_CONTEXT)] == ['private'] + assert await repository.list_skills(other_context) == [] + + +@pytest.mark.asyncio +async def test_repository_rejects_stale_workspace_placement(tmp_path): + repository = _repository(tmp_path) + + async def stale_binding(workspace_uuid, *, expected_generation): + return SimpleNamespace( + instance_uuid=_CONTEXT.instance_uuid, + workspace_uuid=workspace_uuid, + placement_generation=expected_generation + 1, + ) + + repository.ap.workspace_service.get_execution_binding = stale_binding + with pytest.raises(ValueError, match='stale Workspace placement'): + await repository.list_skills(_CONTEXT) + + +@pytest.mark.asyncio +async def test_repository_imports_only_from_the_fenced_workspace(tmp_path): + repository = _repository(tmp_path) + namespace = repository._namespace(_CONTEXT) + source = tmp_path / 'box' / 'default' / 'tenants' / namespace / 'draft' + source.mkdir(parents=True) + (source / 'SKILL.md').write_text( + '---\nname: draft\ndescription: Draft skill\n---\n\nFollow the guide.', + encoding='utf-8', + ) + (source / 'guide.md').write_text('Imported resource', encoding='utf-8') + + scanned = await repository.scan_skill_directory(_CONTEXT, str(source)) + imported = await repository.import_skill_directory( + _CONTEXT, + str(source), + { + 'name': scanned['name'], + 'display_name': scanned['display_name'], + 'description': scanned['description'], + 'instructions': scanned['instructions'], + }, + ) + + assert imported['name'] == 'draft' + resource = await repository.read_skill_file(_CONTEXT, 'draft', 'guide.md') + assert resource['content'] == 'Imported resource' + + outside = tmp_path / 'outside' + outside.mkdir() + (outside / 'SKILL.md').write_text('Outside', encoding='utf-8') + with pytest.raises(ValueError, match='trusted source root'): + await repository.scan_skill_directory(_CONTEXT, str(outside)) + + +@pytest.mark.asyncio +async def test_existing_run_keeps_v1_while_new_run_resolves_v2(tmp_path): + from langbot.pkg.provider.tools.loaders import skill as skill_loader + + repository = _repository(tmp_path) + created = await repository.create_skill( + _CONTEXT, + {'name': 'runner', 'instructions': 'Run scripts/main.py'}, + ) + v1_write = await repository.write_skill_file( + _CONTEXT, + 'runner', + 'scripts/main.py', + "print('v1')", + base_revision=created['revision'], + ) + v1 = await repository.get_skill(_CONTEXT, 'runner', snapshot=True) + old_run = SimpleNamespace(variables={}) + skill_loader.register_activated_skill(old_run, v1) + + await repository.write_skill_file( + _CONTEXT, + 'runner', + 'scripts/main.py', + "print('v2')", + base_revision=v1_write['revision'], + ) + v2 = await repository.get_skill(_CONTEXT, 'runner', snapshot=True) + new_run = SimpleNamespace(variables={}) + skill_loader.register_activated_skill(new_run, v2) + + old_resource = await repository.read_skill_resource( + _CONTEXT, + 'runner', + 'scripts/main.py', + expected_revision=v1['revision'], + ) + new_resource = await repository.read_skill_resource( + _CONTEXT, + 'runner', + 'scripts/main.py', + expected_revision=v2['revision'], + ) + app = SimpleNamespace(logger=Mock()) + old_mount = skill_loader.build_execution_mounts(app, old_run)[0] + new_mount = skill_loader.build_execution_mounts(app, new_run)[0] + + assert old_resource['content'] == "print('v1')" + assert new_resource['content'] == "print('v2')" + assert old_mount['host_path'] == v1['package_root'] + assert new_mount['host_path'] == v2['package_root'] + assert old_mount['content_digest'] == v1['revision'] + assert new_mount['content_digest'] == v2['revision'] + + +@pytest.mark.asyncio +async def test_deleted_skill_can_restore_exact_recoverable_run_revision(tmp_path): + repository = _repository(tmp_path) + published = await repository.create_skill( + _CONTEXT, + {'name': 'recoverable', 'instructions': 'Pinned instructions'}, + ) + await repository.delete_skill(_CONTEXT, 'recoverable') + restored = await repository.get_skill(_CONTEXT, 'recoverable', snapshot=True, revision=published['revision']) + assert restored['revision'] == published['revision'] + assert restored['instructions'] == 'Pinned instructions' diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py index 1855ca337..a66e1472f 100644 --- a/tests/unit_tests/test_skill_service.py +++ b/tests/unit_tests/test_skill_service.py @@ -5,7 +5,6 @@ import zipfile import httpx import pytest -from langbot_plugin.entities.io.errors import ActionCallTimeoutError from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.api.http.service.skill import SkillService @@ -24,120 +23,94 @@ def _workspace_service(): ) -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.""" +class TestSkillRepositoryBoundary: + """Skill management and reads remain available without Box execution.""" - def _ap_with_disabled_box(self): + @staticmethod + def _ap_with_repository(): + repository = SimpleNamespace( + list_skills=AsyncMock(return_value=[{'name': 'x', 'instructions': 'Do work'}]), + get_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:x'}), + create_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:v1'}), + update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated', 'revision': 'sha256:v2'}), + delete_skill=AsyncMock(), + read_skill_file=AsyncMock(return_value={'path': 'a.txt', 'content': 'hello'}), + write_skill_file=AsyncMock(return_value={'path': 'a.txt', 'revision': 'sha256:v3'}), + ) return SimpleNamespace( skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), workspace_service=_workspace_service(), - 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()), - workspace_service=_workspace_service(), - box_service=SimpleNamespace( - available=False, - enabled=True, - _connector_error='docker daemon not running', - ), + box_service=SimpleNamespace(available=False, enabled=False), + skill_repository=repository, ) @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(_CONTEXT, {'name': 'x'}) + async def test_list_and_read_work_when_box_disabled(self): + ap = self._ap_with_repository() + service = SkillService(ap) + + assert await service.list_skills(_CONTEXT) == [{'name': 'x', 'instructions': 'Do work'}] + assert await service.read_skill_file(_CONTEXT, 'x', 'a.txt') == { + 'path': 'a.txt', + 'content': 'hello', + } + ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt') @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(_CONTEXT, {'name': 'x'}) + async def test_create_update_and_write_work_when_box_disabled(self): + ap = self._ap_with_repository() + service = SkillService(ap) + + await service.create_skill(_CONTEXT, {'name': 'x'}) + await service.update_skill( + _CONTEXT, + 'x', + {'instructions': 'Updated', 'base_revision': 'sha256:v1'}, + ) + await service.write_skill_file( + _CONTEXT, + 'x', + 'a.txt', + 'hello', + base_revision='sha256:v2', + ) + + ap.skill_repository.create_skill.assert_awaited_once_with(_CONTEXT, {'name': 'x'}) + ap.skill_repository.update_skill.assert_awaited_once_with( + _CONTEXT, + 'x', + {'instructions': 'Updated'}, + base_revision='sha256:v1', + ) + ap.skill_repository.write_skill_file.assert_awaited_once_with( + _CONTEXT, + 'x', + 'a.txt', + 'hello', + base_revision='sha256:v2', + ) @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(_CONTEXT, 'x', {}) + async def test_get_skill_returns_repository_revision(self): + ap = self._ap_with_repository() + service = SkillService(ap) + + skill = await service.get_skill(_CONTEXT, 'x') + + assert skill['revision'] == 'sha256:x' + ap.skill_repository.get_skill.assert_awaited_once_with(_CONTEXT, 'x', snapshot=True) @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(_CONTEXT, '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( - _CONTEXT, - {'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( - _CONTEXT, - 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.""" + async def test_missing_repository_is_explicit(self): service = SkillService( SimpleNamespace( skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), workspace_service=_workspace_service(), ) ) - with pytest.raises(ValueError, match='not initialised'): + with pytest.raises(ValueError, match='repository is not initialised'): await service.create_skill(_CONTEXT, {'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(_CONTEXT) == [] - - @pytest.mark.asyncio - async def test_list_skills_returns_empty_when_box_action_times_out(self): - """A transient Box list failure should not break unrelated extension surfaces.""" - box_service = SimpleNamespace( - available=True, - list_skills=AsyncMock(side_effect=ActionCallTimeoutError('Action box_list_skills call timed out')), - ) - service = SkillService( - SimpleNamespace( - skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), - workspace_service=_workspace_service(), - box_service=box_service, - ) - ) - - assert await service.list_skills(_CONTEXT) == [] - - @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(_CONTEXT, 'x', 'a.txt') - - class TestGithubSkillArchiveLimits: @staticmethod def _service() -> SkillService: diff --git a/tests/unit_tests/test_telemetry.py b/tests/unit_tests/test_telemetry.py deleted file mode 100644 index ed3994923..000000000 --- a/tests/unit_tests/test_telemetry.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -import asyncio - -import pytest - -from langbot.pkg.telemetry.telemetry import TelemetryManager - - -@pytest.mark.asyncio -async def test_send_tasks_are_scoped_to_manager_instance(monkeypatch): - async def fake_send(self, payload): - return payload - - monkeypatch.setattr(TelemetryManager, 'send', fake_send) - - first = TelemetryManager(SimpleNamespace()) - second = TelemetryManager(SimpleNamespace()) - - assert first.send_tasks is not second.send_tasks - - await first.start_send_task({'event': 'first'}) - task = first.send_tasks[0] - await task - await asyncio.sleep(0) - - assert first.send_tasks == [] - assert second.send_tasks == [] diff --git a/tests/unit_tests/test_workspace_subscription_limits.py b/tests/unit_tests/test_workspace_subscription_limits.py new file mode 100644 index 000000000..c16604ed9 --- /dev/null +++ b/tests/unit_tests/test_workspace_subscription_limits.py @@ -0,0 +1,41 @@ +import asyncio +from types import SimpleNamespace + +import pytest + +from langbot.pkg.cloud.entitlements import EntitlementSnapshot, EntitlementUnavailableError +from langbot.pkg.api.http.service.workspace_limits import workspace_limits + + +def test_oss_retains_instance_limits(): + ap = SimpleNamespace(instance_config=SimpleNamespace(data={'system': {'limitation': {'max_bots': 7}}})) + assert asyncio.run(workspace_limits(ap, 'one')) == {'max_bots': 7} + + +def test_cloud_uses_signed_workspace_limits(): + class Resolver: + async def resolve(self, workspace_uuid, **kwargs): + assert workspace_uuid == 'one' + return EntitlementSnapshot( + instance_uuid='i', + workspace_uuid='one', + entitlement_revision=1, + status='active', + not_before=0, + expires_at=9999999999, + limits={'bots.max': 2, 'pipelines.max': 3, 'plugins.max': 4, 'knowledge_bases.max': 5}, + ) + + ap = SimpleNamespace(deployment=SimpleNamespace(mode='cloud'), entitlement_resolver=Resolver()) + assert asyncio.run(workspace_limits(ap, 'one')) == { + 'max_bots': 2, + 'max_pipelines': 3, + 'max_extensions': 4, + 'max_knowledge_bases': 5, + } + + +def test_cloud_missing_resolver_fails_closed(): + ap = SimpleNamespace(deployment=SimpleNamespace(mode='cloud')) + with pytest.raises(EntitlementUnavailableError): + asyncio.run(workspace_limits(ap, 'one')) diff --git a/tests/unit_tests/utils/test_image.py b/tests/unit_tests/utils/test_image.py index 5c4f9eb50..ceb537aff 100644 --- a/tests/unit_tests/utils/test_image.py +++ b/tests/unit_tests/utils/test_image.py @@ -70,8 +70,8 @@ class TestGetQQImageDownloadableUrl: 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 result_url == 'http://example.com/image.jpg' + assert query == {} def test_https_url(self): """Parse HTTPS URL and preserve its scheme.""" diff --git a/tests/unit_tests/utils/test_importutil.py b/tests/unit_tests/utils/test_importutil.py index cfd7fc23e..5eeb5ee49 100644 --- a/tests/unit_tests/utils/test_importutil.py +++ b/tests/unit_tests/utils/test_importutil.py @@ -15,13 +15,14 @@ Uses mocking for import operations to avoid actual module imports. import pytest import importlib -from unittest.mock import patch, MagicMock +import os +from unittest.mock import patch, MagicMock, call class TestImportDir: """Test import_dir function.""" - def test_calls_importlib_for_each_python_file(self, tmp_path): + def test_calls_importlib_for_each_python_file(self, tmp_path, monkeypatch): """Should call importlib.import_module for each .py file.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -33,12 +34,17 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) 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 + mock_import.assert_has_calls( + [call('test_prefix.test_modules.module_a'), call('test_prefix.test_modules.module_b')], + any_order=True, + ) - def test_skips_init_py(self, tmp_path): + def test_skips_init_py(self, tmp_path, monkeypatch): """Should skip __init__.py when importing.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -48,15 +54,12 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) 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 + mock_import.assert_called_once_with('test_prefix.test_modules.regular') - def test_ignores_non_py_files(self, tmp_path): + def test_ignores_non_py_files(self, tmp_path, monkeypatch): """Should ignore non-.py files.""" module_dir = tmp_path / 'test_modules' module_dir.mkdir() @@ -67,10 +70,10 @@ class TestImportDir: from langbot.pkg.utils import importutil + monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py')) 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 + mock_import.assert_called_once_with('test_prefix.test_modules.module') class TestImportModulesInPkg: @@ -107,7 +110,7 @@ class TestImportModulesInPkgs: 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 + assert mock_import.call_args_list == [call(mock_pkg1), call(mock_pkg2)] class TestImportDotStyleDir: @@ -124,10 +127,7 @@ class TestImportDotStyleDir: 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 + mock_import_dir.assert_called_once_with(os.path.join('my', 'pkg', 'test')) class TestReadResourceFile: diff --git a/tests/unit_tests/utils/test_logcache.py b/tests/unit_tests/utils/test_logcache.py index 775a3ed27..03f2b509a 100644 --- a/tests/unit_tests/utils/test_logcache.py +++ b/tests/unit_tests/utils/test_logcache.py @@ -194,16 +194,11 @@ class TestLogCache: assert result == '' def test_get_log_by_pointer_nonexistent_page(self): - """get_log_by_pointer handles nonexistent page number.""" + """A cursor beyond the tail returns no logs and resumes at the actual tail.""" cache = LogCache() - cache.add_log('log 1') - # Request page that doesn't exist - result, page_num, offset = cache.get_log_by_pointer(99, 0) - - # Returns empty or last available - # Behavior depends on implementation + assert cache.get_log_by_pointer(99, 0) == ('', 0, 1) def test_max_cached_pages_constant(self): """MAX_CACHED_PAGES is defined and reasonable.""" diff --git a/tests/unit_tests/utils/test_paths.py b/tests/unit_tests/utils/test_paths.py index 0043a3338..9ebab2342 100644 --- a/tests/unit_tests/utils/test_paths.py +++ b/tests/unit_tests/utils/test_paths.py @@ -9,7 +9,6 @@ Tests path utility functions: Uses tmp_path for file system isolation where applicable. """ -import os import pytest @@ -45,17 +44,16 @@ class TestCheckIfSourceInstall: class TestGetFrontendPath: """Test get_frontend_path function.""" - def test_returns_web_dist_by_default(self): - """Should return a path containing web/dist as default.""" + def test_returns_web_dist_by_default(self, monkeypatch): + """Should return web/dist when no frontend build exists.""" from langbot.pkg.utils import paths - paths._is_source_install = None + monkeypatch.setattr(paths, '_is_source_install', False) + monkeypatch.setattr(paths.os.path, 'exists', lambda _: False) + monkeypatch.setattr(paths.Path, 'exists', lambda _: False) 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') - - paths._is_source_install = None + assert result == 'web/dist' def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch): """Should find web/dist when running from source mode.""" @@ -130,7 +128,7 @@ class TestGetResourcePath: paths._is_source_install = None result = paths.get_resource_path('templates/config.yaml') - assert os.path.exists(result) + assert result == 'templates/config.yaml' paths._is_source_install = None diff --git a/tests/unit_tests/utils/test_python_workspace.py b/tests/unit_tests/utils/test_python_workspace.py new file mode 100644 index 000000000..6bccb2ef9 --- /dev/null +++ b/tests/unit_tests/utils/test_python_workspace.py @@ -0,0 +1,78 @@ +"""Exercise generated shell/Python syntax without installing dependencies.""" + +import hashlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + +from langbot.pkg.utils.python_workspace import wrap_python_command_with_env + + +class TestPythonWorkspaceCommand(unittest.TestCase): + def test_generated_command_contains_no_null_bytes(self): + command = wrap_python_command_with_env('python main.py') + self.assertNotIn('\0', command) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_generated_command_has_valid_shell_syntax(self): + command = wrap_python_command_with_env('python main.py') + result = subprocess.run(['sh', '-n', '-c', command], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_cached_environment_executes_command_and_hashes_manifest(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + mount = root / 'project' + state = root / 'state' + bin_dir = root / 'bin' + venv_bin = state / '.venv' / 'bin' + meta_dir = state / '.langbot' + for path in (mount, bin_dir, venv_bin, meta_dir): + path.mkdir(parents=True) + + manifest = b'# local fixture\nexample==1.0\n' + (mount / 'requirements.txt').write_bytes(manifest) + # Run the real metadata interpreter, but fail closed if bootstrap is attempted. + system_python = bin_dir / 'python3' + system_python.write_text( + '#!/bin/sh\n' + '[ "$#" -eq 1 ] && [ "$1" = "-" ] || exit 99\n' + f'exec {shlex.quote(sys.executable)} "$@"\n', + encoding='utf-8', + ) + system_python.chmod(0o755) + venv_python = venv_bin / 'python' + venv_python.write_text('#!/bin/sh\nprintf "%s\\n" "$VIRTUAL_ENV"\n', encoding='utf-8') + venv_python.chmod(0o755) + metadata = json.dumps( + { + 'python_executable': sys.executable, + 'python_version': list(sys.version_info[:3]), + 'manifest_files': ['requirements.txt'], + 'manifest_sha256': hashlib.sha256(b'requirements.txt\0' + manifest + b'\0').hexdigest(), + }, + sort_keys=True, + ) + meta_file = meta_dir / 'python-env.json' + meta_file.write_text(metadata, encoding='utf-8') + + command = wrap_python_command_with_env('python', mount_path=str(mount), state_path=str(state)) + result = subprocess.run( + ['sh', '-c', command], + env={**os.environ, 'PATH': f'{bin_dir}{os.pathsep}{os.environ.get("PATH", "")}'}, + capture_output=True, + text=True, + timeout=10, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, f'{state / ".venv"}\n') + self.assertEqual(meta_file.read_text(encoding='utf-8'), metadata) + self.assertFalse((meta_dir / 'python-env.lock').exists()) diff --git a/tests/unit_tests/vector/test_filter_utils.py b/tests/unit_tests/vector/test_filter_utils.py index 2bbf4a1c9..7b8a5e774 100644 --- a/tests/unit_tests/vector/test_filter_utils.py +++ b/tests/unit_tests/vector/test_filter_utils.py @@ -198,6 +198,4 @@ 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) + assert isinstance(SUPPORTED_OPS, frozenset) diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py index a044ac216..43f831e0d 100644 --- a/tests/unit_tests/vector/test_vdb_base.py +++ b/tests/unit_tests/vector/test_vdb_base.py @@ -2,7 +2,6 @@ from __future__ import annotations -from unittest.mock import AsyncMock import pytest from langbot.pkg.vector.vdb import ( @@ -134,89 +133,3 @@ class TestVectorDatabaseAbstractMethods: result = asyncio.run(db.list_by_filter('test_collection')) assert result == ([], -1) - - -class TestVectorDatabaseInterface: - """Tests for VectorDatabase interface contracts.""" - - @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.delete_by_file_id = AsyncMock() - self.delete_by_filter = AsyncMock(return_value=5) - self.get_or_create_collection = AsyncMock() - self.delete_collection = AsyncMock() - - 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, - ): - pass - - async def delete_by_file_id(self, collection, file_id): - pass - - async def delete_by_filter(self, collection, filter): - pass - - async def get_or_create_collection(self, collection): - pass - - async def delete_collection(self, collection): - pass - - return MockVectorDB() - - @pytest.mark.asyncio - async def test_add_embeddings_signature(self, mock_vector_db): - """add_embeddings has expected signature.""" - await mock_vector_db.add_embeddings( - collection='test', - ids=['id1', 'id2'], - embeddings_list=[[0.1, 0.2], [0.3, 0.4]], - metadatas=[{'a': 1}, {'b': 2}], - documents=['doc1', 'doc2'], - ) - mock_vector_db.add_embeddings.assert_called_once() - - @pytest.mark.asyncio - async def test_search_signature(self, mock_vector_db): - """search has expected signature with all optional params.""" - import numpy as np - - await mock_vector_db.search( - collection='test', - query_embedding=np.array([0.1, 0.2]), - k=10, - search_type='hybrid', - query_text='search text', - filter={'file_id': 'abc'}, - vector_weight=0.7, - ) - mock_vector_db.search.assert_called_once() - - @pytest.mark.asyncio - 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) diff --git a/tests/unit_tests/vector/test_vdb_filter_conversion.py b/tests/unit_tests/vector/test_vdb_filter_conversion.py index cc79f62a4..00d9b6ba4 100644 --- a/tests/unit_tests/vector/test_vdb_filter_conversion.py +++ b/tests/unit_tests/vector/test_vdb_filter_conversion.py @@ -10,6 +10,9 @@ from __future__ import annotations from importlib import import_module +import pytest +from sqlalchemy import create_engine, select, text + def get_qdrant_module(): """Lazy import qdrant module.""" @@ -261,6 +264,37 @@ class TestPgVectorFilterConversion: Tests use only these supported fields. """ + @pytest.fixture + def matching_file_ids(self): + """Execute generated predicates against fixed rows, including the range boundary.""" + pgvector_module = get_pgvector_module() + engine = create_engine('sqlite://') + try: + with engine.begin() as connection: + connection.execute(text('CREATE TABLE langbot_vectors (file_id TEXT, chunk_uuid TEXT, text TEXT)')) + connection.execute( + text('INSERT INTO langbot_vectors VALUES (:file_id, :chunk_uuid, :text)'), + [ + {'file_id': 'abc', 'chunk_uuid': 'uuid_099', 'text': 'keep'}, + {'file_id': 'deleted', 'chunk_uuid': 'uuid_100', 'text': ''}, + {'file_id': 'def', 'chunk_uuid': 'uuid_101', 'text': 'keep'}, + {'file_id': 'outside', 'chunk_uuid': 'uuid_102', 'text': 'keep'}, + ], + ) + + def matching(filter_dict): + entry = pgvector_module.PgVectorEntry + statement = ( + select(entry.file_id) + .where(*pgvector_module._build_pg_conditions(filter_dict)) + .order_by(entry.chunk_uuid) + ) + return connection.execute(statement).scalars().all() + + yield matching + finally: + engine.dispose() + def test_empty_filter_returns_empty_list(self): """Empty filter dict returns empty list.""" pgvector_module = get_pgvector_module() @@ -268,73 +302,40 @@ class TestPgVectorFilterConversion: result = pgvector_module._build_pg_conditions({}) assert result == [] - def test_eq_operator_creates_equality_condition(self): + def test_eq_operator_creates_equality_condition(self, matching_file_ids): """$eq operator creates SQLAlchemy == condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': 'abc'}) == ['abc'] - result = pgvector_module._build_pg_conditions({'file_id': 'abc'}) - - 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): + def test_ne_operator_creates_inequality_condition(self, matching_file_ids): """$ne operator creates SQLAlchemy != condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$ne': 'deleted'}}) == ['abc', 'def', 'outside'] - result = pgvector_module._build_pg_conditions({'file_id': {'$ne': 'deleted'}}) - - assert len(result) == 1 - # Operator should be ne (not equals) - assert '!=' in str(result[0]) or 'ne' in str(result[0].operator) - - def test_comparison_operators(self): + def test_comparison_operators(self, matching_file_ids): """$gt, $gte, $lt, $lte create comparison conditions.""" - pgvector_module = get_pgvector_module() - - # Test all comparison operators with supported field - for op, expected_op in [ - ('$gt', '>'), - ('$gte', '>='), - ('$lt', '<'), - ('$lte', '<='), + for op, expected_ids in [ + ('$gt', ['def', 'outside']), + ('$gte', ['deleted', 'def', 'outside']), + ('$lt', ['abc']), + ('$lte', ['abc', 'deleted']), ]: - result = pgvector_module._build_pg_conditions({'chunk_uuid': {op: 'uuid_100'}}) - assert len(result) == 1 - assert expected_op in str(result[0]) + assert matching_file_ids({'chunk_uuid': {op: 'uuid_100'}}) == expected_ids - def test_in_operator_creates_in_condition(self): + def test_in_operator_creates_in_condition(self, matching_file_ids): """$in operator creates SQLAlchemy in_ condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$in': ['abc', 'deleted']}}) == ['abc', 'deleted'] - result = pgvector_module._build_pg_conditions({'file_id': {'$in': ['a', 'b', 'c']}}) - - assert len(result) == 1 - assert 'IN' in str(result[0]).upper() - - def test_nin_operator_creates_notin_condition(self): + def test_nin_operator_creates_notin_condition(self, matching_file_ids): """$nin operator creates SQLAlchemy notin_ condition.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'file_id': {'$nin': ['abc', 'deleted']}}) == ['def', 'outside'] - result = pgvector_module._build_pg_conditions({'file_id': {'$nin': ['a', 'b']}}) - - assert len(result) == 1 - assert 'NOT IN' in str(result[0]).upper() - - def test_multiple_conditions_list(self): + def test_multiple_conditions_list(self, matching_file_ids): """Multiple conditions return list of conditions.""" - pgvector_module = get_pgvector_module() - - result = pgvector_module._build_pg_conditions( + assert matching_file_ids( { - 'file_id': 'abc', - 'chunk_uuid': {'$ne': 'def'}, + 'file_id': {'$in': ['abc', 'deleted']}, + 'chunk_uuid': {'$ne': 'uuid_100'}, } - ) - - assert len(result) == 2 + ) == ['abc'] def test_unsupported_field_dropped(self): """Unsupported fields are dropped (not in _PG_SUPPORTED_FIELDS).""" @@ -343,27 +344,17 @@ class TestPgVectorFilterConversion: result = pgvector_module._build_pg_conditions({'unknown_field': 'value'}) assert result == [] - def test_uuid_alias_resolved(self): + def test_uuid_alias_resolved(self, matching_file_ids): """'uuid' alias is resolved to 'chunk_uuid'.""" - pgvector_module = get_pgvector_module() + assert matching_file_ids({'uuid': 'uuid_100'}) == ['deleted'] - result = pgvector_module._build_pg_conditions({'uuid': 'abc'}) - - assert len(result) == 1 - # Should reference chunk_uuid column - assert 'chunk_uuid' in str(result[0]) - - def test_supported_fields_only(self): + def test_supported_fields_only(self, matching_file_ids): """Only supported fields (text, file_id, chunk_uuid) are kept.""" - pgvector_module = get_pgvector_module() - - result = pgvector_module._build_pg_conditions( + assert matching_file_ids( { 'text': {'$ne': ''}, - 'file_id': 'abc', - 'chunk_uuid': {'$in': ['x', 'y']}, + 'file_id': {'$in': ['abc', 'deleted', 'outside']}, + 'chunk_uuid': {'$in': ['uuid_099', 'uuid_100', 'uuid_101']}, 'unsupported': 'value', } - ) - - assert len(result) == 3 # Only supported fields + ) == ['abc'] diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py index 347524d07..ff070282c 100644 --- a/tests/unit_tests/workspace/test_invitation_delivery.py +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -143,16 +143,24 @@ async def test_oss_smtp_configuration_delivers_the_generic_invitation_email(): } ) ) - service._send_smtp = AsyncMock(return_value=True) link = 'https://self-hosted.example/invitations/accept#token=lbi_secret' - result = await service.deliver_invitation( - recipient_email='member@example.com', - workspace_name='Self-hosted Workspace', - invitation_link=link, - ) + with patch('langbot.pkg.workspace.invitation_delivery.smtplib.SMTP') as smtp: + result = await service.deliver_invitation( + recipient_email='member@example.com', + workspace_name='Self-hosted Workspace', + invitation_link=link, + ) assert result == InvitationDeliveryResult(status='sent', provider='smtp') - service._send_smtp.assert_awaited_once() - assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link) - assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link) + send_message = smtp.return_value.__enter__.return_value.send_message + send_message.assert_called_once() + message = send_message.call_args.args[0] + assert message['From'] == 'LangBot ' + assert message['To'] == 'member@example.com' + assert message['Subject'] == 'You were invited to Self-hosted Workspace' + for subtype in ('plain', 'html'): + content = message.get_body(preferencelist=(subtype,)).get_content() + assert 'Self-hosted Workspace' in content + assert link in content + assert 'LangBot Cloud' not in content diff --git a/tests/utils/legacy_plugin_contract.py b/tests/utils/legacy_plugin_contract.py new file mode 100644 index 000000000..5d4fb15d8 --- /dev/null +++ b/tests/utils/legacy_plugin_contract.py @@ -0,0 +1,61 @@ +"""Load contract fixtures from integrity-checked official plugin source.""" + +import ast +import hashlib +import importlib.util +import json +import os +from pathlib import Path + +import pytest +from langbot_plugin.api.entities.builtin.runner.context import RunnerContext + + +SOURCE_LOCK = Path(__file__).resolve().parents[1] / 'fixtures' / 'legacy_plugin_identity_sources.json' + + +def verified_plugin_root(*, files_key: str = 'files') -> Path: + """Allow optional local execution, but never skip an explicitly provisioned source.""" + configured = os.environ.get('LANGBOT_LEGACY_PLUGIN_ROOT') + if configured is None: + pytest.skip('set LANGBOT_LEGACY_PLUGIN_ROOT to the official revision in legacy_plugin_identity_sources.json') + if not configured.strip(): + pytest.fail('LANGBOT_LEGACY_PLUGIN_ROOT is set but empty', pytrace=False) + root = Path(configured).resolve() + lock = json.loads(SOURCE_LOCK.read_text()) + for relative, expected in lock[files_key].items(): + path = root / relative + if not path.resolve().is_relative_to(root) or not path.is_file(): + pytest.fail(f'Official plugin fixture is missing or outside its root: {relative}', pytrace=False) + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + pytest.fail( + f'Official plugin fixture hash mismatch: {relative}; expected {lock["repository"]}@{lock["revision"]}', + pytrace=False, + ) + return root + + +def load_identity_helper(root: Path, folder: str, method: str, error_name: str): + """Execute actual identity code and its error class without vendor client I/O.""" + plugin = root / 'Runner' / folder + path = plugin / 'components/runner/default.py' + tree = ast.parse(path.read_text()) + runner_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'DefaultRunner'] + assert len(runner_classes) == 1, f'{path}: expected one DefaultRunner' + methods = [node for node in runner_classes[0].body if isinstance(node, ast.FunctionDef) and node.name == method] + assert len(methods) == 1, f'{path}: expected one {method}' + client_path = plugin / 'pkg' / f'{folder.removesuffix("-agent")}_client.py' + client_tree = ast.parse(client_path.read_text()) + errors = [node for node in client_tree.body if isinstance(node, ast.ClassDef) and node.name == error_name] + assert len(errors) == 1, f'{client_path}: expected one {error_name}' + + scoped_path = plugin / 'pkg/scoped_identity.py' + spec = importlib.util.spec_from_file_location(f'identity_contract_{folder.replace("-", "_")}', scoped_path) + assert spec is not None and spec.loader is not None + scoped_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(scoped_module) + namespace = {'RunnerContext': RunnerContext, 'scoped_identity': scoped_module.scoped_identity} + exec(compile(ast.Module(body=errors, type_ignores=[]), str(client_path), 'exec'), namespace) + exec(compile(ast.Module(body=methods, type_ignores=[]), str(path), 'exec'), namespace) + return namespace[method], namespace[error_name] diff --git a/tests/utils/required_suites.py b/tests/utils/required_suites.py new file mode 100644 index 000000000..d752e10c6 --- /dev/null +++ b/tests/utils/required_suites.py @@ -0,0 +1,71 @@ +"""Required suites select explicit markers and enforce execution, not case counts.""" + +import pytest + + +# These are behavior obligations, independent of file names and parametrization. +REQUIRED_CONTRACTS = { + 'postgres': { + 'admission_isolation', + 'rag_identity', + 'rag_recovery', + 'migration_history', + 'migration_fresh_schema', + 'runner_timestamps', + 'monitoring_sessions', + }, + 'local_agent': {'agent_ledger', 'agent_tools', 'agent_rag', 'agent_compaction', 'agent_multi_turn', 'agent_box'}, + 'security': {'debug_exec_absent', 'debug_route_absent'}, + 'compiled_cli': {'cli_http'}, + 'plugin_identity': {'dify_identity', 'n8n_identity', 'coze_identity', 'tbox_identity'}, + 'seekdb': {'vector_round_trip', 'hybrid_relevance'}, +} + + +def pytest_addoption(parser): + parser.addoption('--required-suite', choices=tuple(REQUIRED_CONTRACTS), help='Run a mandatory dependency suite') + + +def pytest_configure(config): + suite = config.getoption('--required-suite') + if suite: + if config.option.markexpr or config.option.keyword or config.option.deselect: + raise pytest.UsageError('--required-suite cannot be combined with -m, -k or --deselect') + config.option.markexpr = suite + config.pluginmanager.register(RequiredSuite(suite), 'required-suite-policy') + + +class RequiredSuite: + def __init__(self, suite): + self.suite = suite + self.items = {} + self.passed = set() + self.unsuccessful = set() + + def pytest_collection_finish(self, session): + for item in session.items: + self.items[item.nodeid] = {mark.args[0] for mark in item.iter_markers('contract')} + covered = set().union(*self.items.values()) + missing = REQUIRED_CONTRACTS[self.suite] - covered + if missing: + raise pytest.UsageError(f'Required suite {self.suite} is missing contracts: {sorted(missing)}') + + def pytest_runtest_logreport(self, report): + if report.nodeid not in self.items: + return + if report.failed or report.skipped or hasattr(report, 'wasxfail'): + self.unsuccessful.add(report.nodeid) + if report.when == 'call' and report.passed: + self.passed.add(report.nodeid) + + def pytest_sessionfinish(self, session, exitstatus): + if session.config.option.collectonly: + return + incomplete = set(self.items) - self.passed + if exitstatus == 0 and (incomplete or self.unsuccessful or not self.items): + session.exitstatus = pytest.ExitCode.TESTS_FAILED + reporter = session.config.pluginmanager.get_plugin('terminalreporter') + if reporter and (incomplete or self.unsuccessful): + reporter.write_sep('=', f'Required suite {self.suite}: all selected cases must pass without skips/xfails') + for nodeid in sorted(incomplete | self.unsuccessful): + reporter.write_line(nodeid) diff --git a/uv.lock b/uv.lock index d43d71643..25f95515e 100644 --- a/uv.lock +++ b/uv.lock @@ -2059,7 +2059,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.11.0b6" +version = "4.11.0" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -2185,7 +2185,7 @@ requires-dist = [ { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, - { name = "langbot-plugin", specifier = "==0.7.10" }, + { name = "langbot-plugin", specifier = "==0.7.13" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2255,7 +2255,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.7.10" +version = "0.7.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2276,9 +2276,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/65013949dee8a8eb38c29b264f5246f2581d6851afd688211fa97b5a0d8d/langbot_plugin-0.7.10.tar.gz", hash = "sha256:5e6b5fea75e5c3696afc0f200a013ef0d63b1e83713935fd05552796c11fb1b2", size = 686205, upload-time = "2026-10-02T00:16:00.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/49/9d3b0ba59ccc11216b7780d99587c483c49c22095ce29b1ed0c2b30ba0a7/langbot_plugin-0.7.13.tar.gz", hash = "sha256:fc8b1127f1774a50b2e0b63c309643ee7fab78a4d5b5fbd0020de1eb1d921e78", size = 713534, upload-time = "2026-10-17T02:20:51.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/d0/9b9c9c42d3f54b8a2ae7537e4c27f1f407f57dcb9f4329aaa2a10e4e1ceb/langbot_plugin-0.7.10-py3-none-any.whl", hash = "sha256:f01957745d1db1a4db630985079662d136da91bfb353883bb59a5d99f6065fa9", size = 441294, upload-time = "2026-10-02T00:15:59.091Z" }, + { url = "https://files.pythonhosted.org/packages/54/f1/23b0e7e37af68781b394a35c95e56b7169fae4ac5e9bb9946ded794a7758/langbot_plugin-0.7.13-py3-none-any.whl", hash = "sha256:f2ef40472907355dd7997bf4e4d5a2dd2bc563f8b3e1e872d3caec6ffea15c81", size = 458360, upload-time = "2026-10-17T02:20:49.759Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json index 554594214..358f146c6 100644 --- a/web/package.json +++ b/web/package.json @@ -55,6 +55,7 @@ "@radix-ui/react-toggle": "^1.1.8", "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.7", + "@remixicon/react": "^4.9.0", "@simplewebauthn/browser": "^14.0.0", "@tailwindcss/postcss": "^4.1.5", "@tanstack/react-table": "^8.21.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fd0c6c5f6..4838d2a6f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,85 +29,88 @@ dependencies: version: 4.6.2 '@hookform/resolvers': specifier: ^5.0.1 - version: 5.2.2(react-hook-form@7.71.1) + version: 5.9.1(react-hook-form@7.90.0)(zod@3.25.76) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-avatar': specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.2.7(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-checkbox': specifier: ^1.3.1 - version: 1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.3.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-collapsible': specifier: ^1.1.12 - version: 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-context-menu': specifier: ^2.2.15 - version: 2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-dialog': specifier: ^1.1.14 - version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-hover-card': specifier: ^1.1.13 - version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-label': specifier: ^2.1.6 - version: 2.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 2.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-popover': specifier: ^1.1.14 - version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-progress': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.17(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-scroll-area': specifier: ^1.2.9 - version: 1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-select': specifier: ^2.2.4 - version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-separator': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-slider': specifier: ^1.4.7 - version: 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.5.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-slot': specifier: ^1.2.3 - version: 1.2.4(@types/react@19.2.10)(react@19.2.1) + version: 1.4.0(@types/react@19.2.18)(react@19.2.1) '@radix-ui/react-switch': specifier: ^1.2.4 - version: 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-tabs': specifier: ^1.1.11 - version: 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.22(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-toggle': specifier: ^1.1.8 - version: 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.19(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-toggle-group': specifier: ^1.1.9 - version: 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) '@radix-ui/react-tooltip': specifier: ^1.2.7 - version: 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@remixicon/react': + specifier: ^4.9.0 + version: 4.9.0(react@19.2.1) '@simplewebauthn/browser': specifier: ^14.0.0 version: 14.0.0 '@tailwindcss/postcss': specifier: ^4.1.5 - version: 4.1.18 + version: 4.3.3 '@tanstack/react-table': specifier: ^8.21.3 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.16) + version: 6.1.2(vite@8.3.4) axios: specifier: ^1.16.0 - version: 1.17.0 + version: 1.20.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -116,19 +119,19 @@ dependencies: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.1.1(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) highlight.js: specifier: ^11.11.1 - version: 11.11.1 + version: 11.12.0 i18next: specifier: ^25.1.2 - version: 25.8.0(typescript@5.9.3) + version: 25.10.10(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.1.0 - version: 8.2.0 + version: 8.2.1 input-otp: specifier: ^1.4.2 - version: 1.4.2(react-dom@19.2.1)(react@19.2.1) + version: 1.5.0(react-dom@19.2.1)(react@19.2.1) lodash: specifier: ^4.18.0 version: 4.18.1 @@ -137,13 +140,13 @@ dependencies: version: 0.507.0(react@19.2.1) postcss: specifier: ^8.5.10 - version: 8.5.15 + version: 8.5.29 qrcode: specifier: ^1.5.4 version: 1.5.4 radix-ui: specifier: ^1.6.0 - version: 1.6.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + version: 1.7.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) react: specifier: 19.2.1 version: 19.2.1 @@ -152,22 +155,22 @@ dependencies: version: 19.2.1(react@19.2.1) react-hook-form: specifier: ^7.56.3 - version: 7.71.1(react@19.2.1) + version: 7.90.0(@types/react@19.2.18)(react@19.2.1) react-i18next: specifier: ^15.5.1 - version: 15.7.4(i18next@25.8.0)(react-dom@19.2.1)(react@19.2.1)(typescript@5.9.3) + version: 15.7.4(i18next@25.10.10)(react-dom@19.2.1)(react@19.2.1)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.10)(react@19.2.1) + version: 10.1.0(@types/react@19.2.18)(react@19.2.1) react-photo-view: specifier: ^1.2.7 version: 1.2.7(react-dom@19.2.1)(react@19.2.1) react-router-dom: specifier: ^7.15.0 - version: 7.17.0(react-dom@19.2.1)(react@19.2.1) + version: 7.18.4(react-dom@19.2.1)(react@19.2.1) react-syntax-highlighter: specifier: ^16.1.0 - version: 16.1.0(react@19.2.1) + version: 16.1.2(react@19.2.1) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.1)(react@19.2.1) @@ -191,19 +194,19 @@ dependencies: version: 4.0.1 sonner: specifier: ^2.0.3 - version: 2.0.7(react-dom@19.2.1)(react@19.2.1) + version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) tailwind-merge: specifier: ^3.2.0 - version: 3.4.0 + version: 3.7.0 tailwindcss: specifier: ^4.1.5 - version: 4.1.18 + version: 4.3.3 uuidjs: specifier: ^5.1.0 version: 5.1.0 vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@20.19.30) + version: 8.3.4(@types/node@20.19.43) zod: specifier: ^3.24.4 version: 3.25.76 @@ -211,22 +214,22 @@ dependencies: devDependencies: '@playwright/test': specifier: ^1.61.0 - version: 1.61.0 + version: 1.64.0 '@types/debug': specifier: ^4.1.12 - version: 4.1.12 + version: 4.1.13 '@types/estree': specifier: ^1.0.8 - version: 1.0.8 + version: 1.0.9 '@types/estree-jsx': specifier: ^1.0.5 version: 1.0.5 '@types/hast': specifier: ^3.0.4 - version: 3.0.4 + version: 3.0.5 '@types/lodash': specifier: ^4.17.16 - version: 4.17.23 + version: 4.17.26 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -235,16 +238,16 @@ devDependencies: version: 2.1.0 '@types/node': specifier: ^20 - version: 20.19.30 + version: 20.19.43 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 '@types/react': specifier: ~19.2.7 - version: 19.2.10 + version: 19.2.18 '@types/react-dom': specifier: ~19.2.3 - version: 19.2.3(@types/react@19.2.10) + version: 19.2.7(@types/react@19.2.18) '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 @@ -253,25 +256,25 @@ devDependencies: version: 3.0.3 eslint: specifier: ^9 - version: 9.39.2 + version: 9.39.5 eslint-config-prettier: specifier: ^10.1.2 - version: 10.1.8(eslint@9.39.2) + version: 10.1.8(eslint@9.39.5) eslint-plugin-prettier: specifier: ^5.2.6 - version: 5.5.5(eslint-config-prettier@10.1.8)(eslint@9.39.2)(prettier@3.8.1) + version: 5.5.6(eslint-config-prettier@10.1.8)(eslint@9.39.5)(prettier@3.9.10) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.2) + version: 7.37.5(eslint@9.39.5) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.2) + version: 5.2.0(eslint@9.39.5) lint-staged: specifier: ^15.5.1 version: 15.5.2 prettier: specifier: ^3.5.3 - version: 3.8.1 + version: 3.9.10 tw-animate-css: specifier: ^1.2.9 version: 1.4.0 @@ -280,17 +283,17 @@ devDependencies: version: 5.9.3 typescript-eslint: specifier: ^8.31.1 - version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) + version: 8.71.1(eslint@9.39.5)(typescript@5.9.3) packages: - /@alloc/quick-lru@5.2.0: - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + /@alloc/quick-lru@5.3.0: + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} dev: false - /@babel/runtime@7.28.6: - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + /@babel/runtime@7.29.10: + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} dev: false @@ -337,38 +340,13 @@ packages: tslib: 2.8.1 dev: false - /@emnapi/core@1.10.0: - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - requiresBuild: true - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - dev: false - optional: true - - /@emnapi/runtime@1.10.0: - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - requiresBuild: true - dependencies: - tslib: 2.8.1 - dev: false - optional: true - - /@emnapi/wasi-threads@1.2.1: - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - requiresBuild: true - dependencies: - tslib: 2.8.1 - dev: false - optional: true - - /@eslint-community/eslint-utils@4.9.1(eslint@9.39.2): - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + /@eslint-community/eslint-utils@4.10.1(eslint@9.39.5): + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 9.39.2 + eslint: 9.39.5 eslint-visitor-keys: 3.4.3 dev: true @@ -377,13 +355,13 @@ packages: engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/config-array@0.21.1: - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + /@eslint/config-array@0.21.2: + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.3 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color dev: true @@ -402,25 +380,25 @@ packages: '@types/json-schema': 7.0.15 dev: true - /@eslint/eslintrc@3.3.3: - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + /@eslint/eslintrc@3.3.7: + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 - minimatch: 3.1.3 + js-yaml: 4.3.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color dev: true - /@eslint/js@9.39.2: - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + /@eslint/js@9.39.5: + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true @@ -443,56 +421,138 @@ packages: tslib: 2.8.1 dev: false - /@floating-ui/core@1.7.4: - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + /@floating-ui/core@1.8.0: + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.12 dev: false - /@floating-ui/dom@1.7.5: - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + /@floating-ui/dom@1.8.0: + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 dev: false - /@floating-ui/react-dom@2.1.7(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + /@floating-ui/react-dom@2.1.9(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.8.0 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@floating-ui/utils@0.2.10: - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + /@floating-ui/utils@0.2.12: + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} dev: false - /@hookform/resolvers@5.2.2(react-hook-form@7.71.1): - resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + /@hookform/resolvers@5.9.1(react-hook-form@7.90.0)(zod@3.25.76): + resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.2.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 || ^18.0.0 + nope-validator: '>=0.12.0' react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=6.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.71.1(react@19.2.1) + react-hook-form: 7.90.0(@types/react@19.2.18)(react@19.2.1) + zod: 3.25.76 dev: false - /@humanfs/core@0.19.1: - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - dev: true - - /@humanfs/node@0.16.7: - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + /@humanfs/core@0.19.2: + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + dev: true + + /@humanfs/node@0.16.8: + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 dev: true + /@humanfs/types@0.15.0: + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + dev: true + /@humanwhocodes/module-importer@1.0.1: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} @@ -506,7 +566,7 @@ packages: /@jridgewell/gen-mapping@0.3.13: resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 dev: false @@ -522,65 +582,44 @@ packages: engines: {node: '>=6.0.0'} dev: false - /@jridgewell/sourcemap-codec@1.5.5: - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + /@jridgewell/sourcemap-codec@1.6.0: + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} dev: false /@jridgewell/trace-mapping@0.3.31: resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 dev: false - /@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.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 - dev: false - optional: true - - /@oxc-project/types@0.133.0: - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + /@oxc-project/types@0.153.0: + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} dev: false - /@pkgr/core@0.2.9: - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + /@pkgr/core@0.3.6: + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} dev: true - /@playwright/test@1.61.0: - resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} - engines: {node: '>=18'} + /@playwright/test@1.64.0: + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} hasBin: true dependencies: - playwright: 1.61.0 + playwright: 1.64.0 dev: true - /@radix-ui/number@1.1.1: - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - dev: false - /@radix-ui/number@1.1.3: resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} dev: false - /@radix-ui/primitive@1.1.3: - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - dev: false - /@radix-ui/primitive@1.1.7: resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} dev: false - /@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==} + /@radix-ui/react-accessible-icon@1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-I+T/Hc6SY3WbFVZ9Ne1o0DJbpjQpTpLIBZcVzYiHCQ9z1yGUd/G88u5SKyM6eJ223txAOS7bp7bb9k0Ocfkjwg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -592,15 +631,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} + /@radix-ui/react-accordion@1.2.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-2fmc6COuKLq2k3P7JZrOt9Zf8HT9nTQhHN5V+A3ih7ftqoyDcoEUrPoLq8ngr7QwTd7hpQ2xoUKWtvy1Xw9RAQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -613,47 +652,22 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collapsible': 1.1.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} + /@radix-ui/react-alert-dialog@1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-dRvB59HhcjIWcCPKzK5Px1yQ8pIULQQrxqgyepCqin7tNXi/xvmC86WkyMOKhuCbjz3+v9Q6oE7/tUN5NQ9dCg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -666,18 +680,18 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dialog': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + /@radix-ui/react-arrow@1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -689,15 +703,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + /@radix-ui/react-aspect-ratio@1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-w2u6oiTzb7WkWwV3ZClX/llYYlTbilUOkHIKkwb+PWWytQ/IdEVRNLIChjbcXy/mkqcKKM3wEhrOilJW+uPHDg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -709,59 +723,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} + /@radix-ui/react-avatar@1.2.7(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-6AizCpbU3Zngojp9vQak8Lt4AXwdKJ2lkRYAfpoETraT+qKVNeRqytiADzneHetFEB0Wu4+qeSfmZEq0PiVDmQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -774,19 +744,19 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} + /@radix-ui/react-checkbox@1.3.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-gNOS2lLrpQxNLWHhCX0sayfVRiyB5gQp7nQIzpl+PM1Hkab52utsKtD27alAr78uqhpvX51kwqDp71hM5hNSwg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -799,74 +769,20 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} + /@radix-ui/react-collapsible@1.1.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-IzkusZHCZBnED5skW+b+syl8d+e3+zelvxZ2Ed9lfawqEBfa5osPj53sTJ+q/uNmGAj9e7wMYgyzwPu/luWJ4Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -879,21 +795,21 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + /@radix-ui/react-collection@1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -905,53 +821,17 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} peerDependencies: '@types/react': '*' @@ -960,37 +840,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} + /@radix-ui/react-context-menu@2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-s8ABBkEuAOyhDub0Y99f8lVRlVsd28mUnOHtuqGg8LuHnefkmPN5rYzA2g8MAPLjAZMisnDHhN4dBHUM7ZF7Rg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1003,43 +858,17 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-context@1.1.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-context@1.1.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-context@1.2.2(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} peerDependencies: '@types/react': '*' @@ -1048,12 +877,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + /@radix-ui/react-dialog@1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1065,28 +894,42 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.1) dev: false - /@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + /@radix-ui/react-direction@1.1.5(@types/react@19.2.18)(react@19.2.1): + resolution: {integrity: sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 19.2.18 + react: 19.2.1 + dev: false + + /@radix-ui/react-dismissable-layer@1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1099,78 +942,18 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) - dev: false - - /@radix-ui/react-direction@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-direction@1.1.4(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + /@radix-ui/react-dropdown-menu@2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1183,82 +966,19 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} peerDependencies: '@types/react': '*' @@ -1267,12 +987,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + /@radix-ui/react-focus-scope@1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1284,39 +1004,17 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-form@0.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==} + /@radix-ui/react-form@0.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-5ELeC+bQUHXEjU51svw11DynjGFlaUjo5QOUCr5hptvLLtBiqWtva2O3IWdjrFWQwitw6k6KpHHEyI61/kCVeg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1329,47 +1027,19 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-label': 2.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==} + /@radix-ui/react-hover-card@1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1382,35 +1052,22 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-id@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-id@1.1.4(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: '@types/react': '*' @@ -1419,13 +1076,13 @@ packages: '@types/react': optional: true dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-label@2.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} + /@radix-ui/react-label@2.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-2vTQj72YFmeoTxWKJEMbO/9uQt6zzLgbzi0+nWBffq49oo6KhIDP/D+fA2im0wsxBCAX7QfaF3xgsYBVdDbNFQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1437,15 +1094,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + /@radix-ui/react-menu@2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1457,52 +1114,32 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.1) dev: false - /@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + /@radix-ui/react-menubar@1.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-JIQiKcXsgl6BtwcMe7BMLvCGH2p9Czgcmxl6Ie9Zoi+MW1mmitkcm5We01m/ndLYVI8PpRJnM/RcLV0eOCxaJQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1515,31 +1152,23 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) dev: false - /@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==} + /@radix-ui/react-navigation-menu@1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-hZqTqDwc8Z5aI4rGIyPqCSJ2JoSNorqX/F1/gqNHkZzWJO6JuYzL7hn5sWOZJjpkfT+cbxWWRbKVU/IeiOM9ag==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1552,56 +1181,28 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==} + /@radix-ui/react-one-time-password-field@0.1.17(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-+6jmOqjSNBr7F1uSc7HSDqD3XL54A2qA99I9rIH4JoOD/jDScw8tUmDz6HCAkjytbrE/cdL66i+QkeDyZ0WNyw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1615,24 +1216,24 @@ packages: dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==} + /@radix-ui/react-password-toggle-field@0.1.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-+SWSgf3HRpE4zUira+p6t5KUR473PSqewtQgj9qzl3znZPRWPCGycwXNTfHwkiRTD3tEXwNIXJg0i1NTteiULw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1645,55 +1246,21 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) - dev: false - - /@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + /@radix-ui/react-popover@1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1706,28 +1273,29 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.1) dev: false - /@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + /@radix-ui/react-popper@1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1739,53 +1307,24 @@ packages: '@types/react-dom': optional: true dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/rect': 1.1.1 - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-arrow': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) '@radix-ui/rect': 1.1.3 - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + /@radix-ui/react-portal@1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1797,16 +1336,16 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + /@radix-ui/react-presence@1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1818,16 +1357,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + /@radix-ui/react-primitive@2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1839,15 +1377,15 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + /@radix-ui/react-progress@1.1.17(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-A+RfMARd9u+UaVbLQDmysGlAGuaFQrF+uxF3v+7+xU8tkKoYxtCSs+6HHnIKJVgam2sGZ7c0hLRPWSnmnFyGJQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1859,118 +1397,16 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} + /@radix-ui/react-radio-group@1.4.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-MaOOZ1TlaYoOeSDnHeVIONss0rp5ngOTY5HJY+Xkq16PAopnj21iU9r9m0O/77cBYu8Ksaq3BcjlaOdpbfbzzg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1983,50 +1419,22 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + /@radix-ui/react-roving-focus@1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2039,52 +1447,24 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} + /@radix-ui/react-scroll-area@1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-1WSuAXaahfeLmFX/dfYo/ftg1filMfaWjVye2d40VlgtWMMq/NOVocfukZEXkG+dwrAoNI+qpo9bjhNSJ/XC8g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2098,21 +1478,22 @@ packages: dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + /@radix-ui/react-select@2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-Tz9xOpV9Rbca5WTk7pzmfAECV3zb++uScT9mP3Px7ziAhH7SlK53b0uwMw8VubPhRK4x7uDKCy5fwevT8I0fAg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2124,35 +1505,56 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) aria-hidden: 1.2.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.1) dev: false - /@radix-ui/react-select@2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} + /@radix-ui/react-separator@1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + dev: false + + /@radix-ui/react-slider@1.5.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-ZOBLK7KZvGL02V+4VwVbj8NYM4vPrAtyZdrgHSv+AMS9VL+5yAdnQF3DZT4Y8cbCp1Yn4xdp92orvez8fJa77w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2166,104 +1568,23 @@ packages: dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - aria-hidden: 1.2.6 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-remove-scroll: 2.7.2(@types/react@19.2.10)(react@19.2.1) - dev: false - - /@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/number': 1.1.3 - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + /@radix-ui/react-slot@1.4.0(@types/react@19.2.18)(react@19.2.1): + resolution: {integrity: sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2271,67 +1592,13 @@ packages: '@types/react': optional: true dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-slot@1.2.4(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-slot@1.3.3(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} + /@radix-ui/react-switch@1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-rr+zuoAeHWK3m6H4HgjOUiu3VsrQ/9JkjwY/uMnTgHJGPe9WNzDP/MOuIV7S+65p42DIxjGL0hAHktqBq82+2A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2344,46 +1611,19 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + /@radix-ui/react-tabs@1.1.22(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2396,21 +1636,21 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} + /@radix-ui/react-toast@1.2.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-XR9pDEz9CH0ez98zPXQZOpF46anvbarkoOpjXpPSX7gm21vEl5Tzoe1PSQd3EY1OTOHac4lNq+TPx/2Z7jObdQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2423,51 +1663,25 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} + /@radix-ui/react-toggle-group@1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-O+Sjiv64DfnByHYtnKEXmn63uUskU/X61vjf69X1v8JFn/ZvuNNLdJTBfYurVTSDv4/RJnJGtGgb/uvuS/rRJw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2480,42 +1694,20 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle': 1.1.19(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} + /@radix-ui/react-toggle@1.1.19(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-l7FQHtuZjUJ0vKLdhKu5LzZ8Jw6K60QEC1tC22QNLuXN2PkydSoqEhKTCqwxBFZJ52NTCjzudDvl9sUsZeUbPw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2528,16 +1720,16 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==} + /@radix-ui/react-toolbar@1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-5gyzL6B5/zjBwU3ATBWAVZ4xXQC5F8E9CB4BmYC1UvZ08pN2OJQoooeIwJtZZ/2rz4sTiQmbGfdDvmU73wRJCQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2550,20 +1742,20 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-separator': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle-group': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} + /@radix-ui/react-tooltip@1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-4HM4b1Cft1CBXCGDRDG3S/fh8GedBuVOMNh145wMIfTbVqcuv+tyj03nZqYgFevWJMYo58H0s+8P6s6NaUHWtA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2576,69 +1768,25 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} peerDependencies: '@types/react': '*' @@ -2647,26 +1795,11 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: '@types/react': '*' @@ -2676,27 +1809,13 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} peerDependencies: '@types/react': '*' @@ -2705,26 +1824,12 @@ packages: '@types/react': optional: true dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==} peerDependencies: '@types/react': '*' @@ -2733,26 +1838,12 @@ packages: '@types/react': optional: true dependencies: - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - use-sync-external-store: 1.6.0(react@19.2.1) - dev: false - - /@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} peerDependencies: '@types/react': '*' @@ -2761,24 +1852,11 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} peerDependencies: '@types/react': '*' @@ -2787,24 +1865,11 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-previous@1.1.4(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} peerDependencies: '@types/react': '*' @@ -2813,25 +1878,11 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/rect': 1.1.1 - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-use-rect@1.1.4(@types/react@19.2.10)(react@19.2.1): + /@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} peerDependencies: '@types/react': '*' @@ -2841,12 +1892,12 @@ packages: optional: true dependencies: '@radix-ui/rect': 1.1.3 - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-size@1.1.1(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + /@radix-ui/react-use-size@1.1.5(@types/react@19.2.18)(react@19.2.1): + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2854,27 +1905,13 @@ packages: '@types/react': optional: true dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@types/react': 19.2.18 react: 19.2.1 dev: false - /@radix-ui/react-use-size@1.1.4(@types/react@19.2.10)(react@19.2.1): - resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@types/react': 19.2.10 - react: 19.2.1 - dev: false - - /@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + /@radix-ui/react-visually-hidden@1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-/iPLVHRBzKWvekN+5xaDmXw80f+nleYYceomVORrGjuJe0LF2SCbSH4f/LG4hu32q92QSuPZx4hmSACYBVBfzQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2886,43 +1923,36 @@ packages: '@types/react-dom': optional: true dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - dev: false - - /@radix-ui/rect@1.1.1: - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - dev: false - /@radix-ui/rect@1.1.3: resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} dev: false - /@rolldown/binding-android-arm64@1.0.3: - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + /@remixicon/react@4.9.0(react@19.2.1): + resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==} + peerDependencies: + react: '>=18.2.0' + dependencies: + react: 19.2.1 + dev: false + + /@rolldown/binding-android-arm-eabi@1.2.13: + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@rolldown/binding-android-arm64@1.2.13: + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] @@ -2930,8 +1960,8 @@ packages: dev: false optional: true - /@rolldown/binding-darwin-arm64@1.0.3: - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + /@rolldown/binding-darwin-arm64@1.2.13: + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] @@ -2939,8 +1969,8 @@ packages: dev: false optional: true - /@rolldown/binding-darwin-x64@1.0.3: - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + /@rolldown/binding-darwin-x64@1.2.13: + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] @@ -2948,8 +1978,8 @@ packages: dev: false optional: true - /@rolldown/binding-freebsd-x64@1.0.3: - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + /@rolldown/binding-freebsd-x64@1.2.13: + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] @@ -2957,8 +1987,8 @@ packages: dev: false optional: true - /@rolldown/binding-linux-arm-gnueabihf@1.0.3: - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + /@rolldown/binding-linux-arm-gnueabihf@1.2.13: + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -2966,68 +1996,62 @@ packages: dev: false optional: true - /@rolldown/binding-linux-arm64-gnu@1.0.3: - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + /@rolldown/binding-linux-arm64-gnu@1.2.13: + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@rolldown/binding-linux-arm64-musl@1.0.3: - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + /@rolldown/binding-linux-arm64-musl@1.2.13: + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /@rolldown/binding-linux-ppc64-gnu@1.0.3: - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + /@rolldown/binding-linux-ppc64-gnu@1.2.13: + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@rolldown/binding-linux-s390x-gnu@1.0.3: - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + /@rolldown/binding-linux-s390x-gnu@1.2.13: + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@rolldown/binding-linux-x64-gnu@1.0.3: - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + /@rolldown/binding-linux-x64-gnu@1.2.13: + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@rolldown/binding-linux-x64-musl@1.0.3: - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + /@rolldown/binding-linux-x64-musl@1.2.13: + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /@rolldown/binding-openharmony-arm64@1.0.3: - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + /@rolldown/binding-openharmony-arm64@1.2.13: + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] @@ -3035,20 +2059,8 @@ packages: dev: false optional: true - /@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.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.3: - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + /@rolldown/binding-win32-arm64-msvc@1.2.13: + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] @@ -3056,8 +2068,8 @@ packages: dev: false optional: true - /@rolldown/binding-win32-x64-msvc@1.0.3: - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + /@rolldown/binding-win32-x64-msvc@1.2.13: + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -3065,10 +2077,6 @@ packages: dev: false optional: true - /@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 @@ -3081,105 +2089,101 @@ packages: resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} dev: false - /@tailwindcss/node@4.1.18: - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + /@tailwindcss/node@4.3.3: + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.4 - jiti: 2.6.1 - lightningcss: 1.30.2 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 dev: false - /@tailwindcss/oxide-android-arm64@4.1.18: - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-android-arm64@4.3.3: + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-darwin-arm64@4.1.18: - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-darwin-arm64@4.3.3: + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-darwin-x64@4.1.18: - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-darwin-x64@4.3.3: + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-freebsd-x64@4.1.18: - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-freebsd-x64@4.3.3: + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18: - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3: + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-linux-arm64-gnu@4.1.18: - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-linux-arm64-gnu@4.3.3: + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-linux-arm64-musl@4.1.18: - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-linux-arm64-musl@4.3.3: + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-linux-x64-gnu@4.1.18: - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-linux-x64-gnu@4.3.3: + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-linux-x64-musl@4.1.18: - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-linux-x64-musl@4.3.3: + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-wasm32-wasi@4.1.18: - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + /@tailwindcss/oxide-wasm32-wasi@4.3.3: + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} engines: {node: '>=14.0.0'} cpu: [wasm32] requiresBuild: true @@ -3193,50 +2197,50 @@ packages: - '@emnapi/wasi-threads' - tslib - /@tailwindcss/oxide-win32-arm64-msvc@4.1.18: - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-win32-arm64-msvc@4.3.3: + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide-win32-x64-msvc@4.1.18: - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + /@tailwindcss/oxide-win32-x64-msvc@4.3.3: + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] requiresBuild: true dev: false optional: true - /@tailwindcss/oxide@4.1.18: - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + /@tailwindcss/oxide@4.3.3: + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 dev: false - /@tailwindcss/postcss@4.1.18: - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + /@tailwindcss/postcss@4.3.3: + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.15 - tailwindcss: 4.1.18 + '@alloc/quick-lru': 5.3.0 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + postcss: 8.5.29 + tailwindcss: 4.3.3 dev: false /@tanstack/react-table@8.21.3(react-dom@19.2.1)(react@19.2.1): @@ -3256,14 +2260,6 @@ packages: engines: {node: '>=12'} dev: false - /@tybys/wasm-util@0.10.1: - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - requiresBuild: true - dependencies: - tslib: 2.8.1 - dev: false - optional: true - /@types/d3-array@3.2.2: resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} dev: false @@ -3292,8 +2288,8 @@ packages: '@types/d3-time': 3.0.4 dev: false - /@types/d3-shape@3.1.8: - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + /@types/d3-shape@3.2.0: + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} dependencies: '@types/d3-path': 3.1.1 dev: false @@ -3306,21 +2302,21 @@ packages: resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} dev: false - /@types/debug@4.1.12: - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + /@types/debug@4.1.13: + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} dependencies: '@types/ms': 2.1.0 /@types/estree-jsx@1.0.5: resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 - /@types/estree@1.0.8: - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + /@types/estree@1.0.9: + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - /@types/hast@3.0.4: - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + /@types/hast@3.0.5: + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} dependencies: '@types/unist': 3.0.3 @@ -3328,8 +2324,8 @@ packages: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true - /@types/lodash@4.17.23: - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + /@types/lodash@4.17.26: + resolution: {integrity: sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==} dev: true /@types/mdast@4.0.4: @@ -3340,36 +2336,36 @@ packages: /@types/ms@2.1.0: resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - /@types/node@20.19.30: - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + /@types/node@20.19.43: + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} dependencies: undici-types: 6.21.0 - /@types/prismjs@1.26.5: - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + /@types/prismjs@1.26.6: + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} dev: false /@types/qrcode@1.5.6: resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} dependencies: - '@types/node': 20.19.30 + '@types/node': 20.19.43 dev: true - /@types/react-dom@19.2.3(@types/react@19.2.10): - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + /@types/react-dom@19.2.7(@types/react@19.2.18): + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: '@types/react': ^19.2.0 dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 /@types/react-syntax-highlighter@15.5.13: resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 dev: true - /@types/react@19.2.10: - resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + /@types/react@19.2.18: + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} dependencies: csstype: 3.2.3 @@ -3380,177 +2376,180 @@ packages: /@types/unist@3.0.3: resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - /@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0)(eslint@9.39.2)(typescript@5.9.3): - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + /@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1)(eslint@9.39.5)(typescript@5.9.3): + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': ^8.71.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2 - ignore: 7.0.5 + '@typescript-eslint/parser': 8.71.1(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 + eslint: 9.39.5 + ignore: 7.0.12 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3): - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + /@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@5.9.3): + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3 - eslint: 9.39.2 + eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/project-service@8.54.0(typescript@5.9.3): - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + /@typescript-eslint/project-service@8.71.1(typescript@5.9.3): + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@8.54.0: - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + /@typescript-eslint/scope-manager@8.71.1: + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 dev: true - /@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3): - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + /@typescript-eslint/tsconfig-utils@8.71.1(typescript@5.9.3): + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' dependencies: typescript: 5.9.3 dev: true - /@typescript-eslint/type-utils@8.54.0(eslint@9.39.2)(typescript@5.9.3): - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + /@typescript-eslint/type-utils@8.71.1(eslint@9.39.5)(typescript@5.9.3): + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.5)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2 - ts-api-utils: 2.4.0(typescript@5.9.3) + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@8.54.0: - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + /@typescript-eslint/types@8.71.1: + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true - /@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3): - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + /@typescript-eslint/typescript-estree@8.71.1(typescript@5.9.3): + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.71.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3 - minimatch: 9.0.7 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@8.54.0(eslint@9.39.2)(typescript@5.9.3): - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + /@typescript-eslint/utils@8.71.1(eslint@9.39.5)(typescript@5.9.3): + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/visitor-keys@8.54.0: - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + /@typescript-eslint/visitor-keys@8.71.1: + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: - '@typescript-eslint/types': 8.54.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.71.1 + eslint-visitor-keys: 5.0.1 dev: true - /@ungap/structured-clone@1.3.0: - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + /@ungap/structured-clone@1.4.0: + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} dev: false - /@vitejs/plugin-react@6.0.1(vite@8.0.16): - resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + /@vitejs/plugin-react@6.1.2(vite@8.3.4): + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': optional: true babel-plugin-react-compiler: optional: true + oxc-transform-react: + optional: true dependencies: - '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.16(@types/node@20.19.30) + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@20.19.43) dev: false - /acorn-jsx@5.3.2(acorn@8.15.0): + /acorn-jsx@5.3.2(acorn@8.19.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - acorn: 8.15.0 + acorn: 8.19.0 dev: true - /acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + /acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true dev: true @@ -3564,8 +2563,8 @@ packages: - supports-color dev: false - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + /ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -3573,8 +2572,8 @@ packages: uri-js: 4.4.1 dev: true - /ansi-escapes@7.2.0: - resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + /ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} dependencies: environment: 1.1.0 @@ -3585,8 +2584,8 @@ packages: engines: {node: '>=8'} dev: false - /ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + /ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} dev: true @@ -3620,16 +2619,16 @@ packages: is-array-buffer: 3.0.5 dev: true - /array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + /array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 is-string: 1.1.1 math-intrinsics: 1.1.0 dev: true @@ -3638,11 +2637,11 @@ packages: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 dev: true @@ -3650,9 +2649,9 @@ packages: resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 dev: true @@ -3660,9 +2659,9 @@ packages: resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 dev: true @@ -3670,9 +2669,9 @@ packages: resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 dev: true @@ -3682,9 +2681,9 @@ packages: engines: {node: '>= 0.4'} dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -3706,10 +2705,10 @@ packages: possible-typed-array-names: 1.1.0 dev: true - /axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + /axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.1 form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -3731,16 +2730,16 @@ packages: engines: {node: 18 || 20 || >=22} dev: true - /brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + /brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} dependencies: balanced-match: 1.0.2 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} + /brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} dependencies: balanced-match: 4.0.4 dev: true @@ -3759,8 +2758,8 @@ packages: es-errors: 1.3.0 function-bind: 1.1.2 - /call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + /call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} dependencies: call-bind-apply-helpers: 1.0.2 @@ -3854,16 +2853,16 @@ packages: engines: {node: '>=6'} dev: false - /cmdk@1.1.1(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): + /cmdk@1.1.1(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dialog': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) transitivePeerDependencies: @@ -4106,7 +3105,7 @@ packages: /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.10 csstype: 3.2.3 dev: false @@ -4126,12 +3125,12 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false - /enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + /enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 dev: false /entities@6.0.1: @@ -4144,24 +3143,34 @@ packages: engines: {node: '>=18'} dev: true - /es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + /es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + dev: true + + /es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} 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-bind: 1.0.9 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-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -4170,7 +3179,7 @@ packages: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -4186,22 +3195,22 @@ packages: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 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.trim: 1.2.11 + string.prototype.trimend: 1.0.10 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 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 dev: true /es-define-property@1.0.1: @@ -4212,14 +3221,14 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - /es-iterator-helpers@1.3.1: - resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + /es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -4232,11 +3241,10 @@ packages: internal-slot: 1.1.0 iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - safe-array-concat: 1.1.3 dev: true - /es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + /es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 @@ -4248,19 +3256,22 @@ packages: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 /es-shim-unscopables@1.1.0: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} dependencies: - hasown: 2.0.2 + hasown: 2.0.4 dev: true - /es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + /es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 @@ -4276,17 +3287,17 @@ packages: engines: {node: '>=12'} dev: false - /eslint-config-prettier@10.1.8(eslint@9.39.2): + /eslint-config-prettier@10.1.8(eslint@9.39.5): resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 9.39.2 + eslint: 9.39.5 dev: true - /eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8)(eslint@9.39.2)(prettier@3.8.1): - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + /eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8)(eslint@9.39.5)(prettier@3.9.10): + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -4299,46 +3310,46 @@ packages: eslint-config-prettier: optional: true dependencies: - eslint: 9.39.2 - eslint-config-prettier: 10.1.8(eslint@9.39.2) - prettier: 3.8.1 + eslint: 9.39.5 + eslint-config-prettier: 10.1.8(eslint@9.39.5) + prettier: 3.9.10 prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 + synckit: 0.11.13 dev: true - /eslint-plugin-react-hooks@5.2.0(eslint@9.39.2): + /eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} 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 dependencies: - eslint: 9.39.2 + eslint: 9.39.5 dev: true - /eslint-plugin-react@7.37.5(eslint@9.39.2): + /eslint-plugin-react@7.37.5(eslint@9.39.5): resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 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.3.1 - eslint: 9.39.2 + es-iterator-helpers: 1.4.0 + eslint: 9.39.5 estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.3 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.6 + resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.12 + string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 dev: true @@ -4360,9 +3371,15 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true - /eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + /eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + dev: true + + /eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -4370,19 +3387,19 @@ packages: jiti: optional: true dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -4401,7 +3418,7 @@ packages: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.3 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -4412,8 +3429,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 4.2.1 dev: true @@ -4480,8 +3497,8 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true - /fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + /fast-equals@5.4.4: + resolution: {integrity: sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==} engines: {node: '>=6.0.0'} dev: false @@ -4499,7 +3516,7 @@ packages: format: 0.2.2 dev: false - /fdir@6.5.0(picomatch@4.0.4): + /fdir@6.5.0(picomatch@4.0.7): resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: @@ -4508,7 +3525,7 @@ packages: picomatch: optional: true dependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 /file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -4544,16 +3561,16 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 dev: true - /flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + /flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} dev: true - /follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + /follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -4585,14 +3602,6 @@ 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} @@ -4604,16 +3613,19 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - /function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + /function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 - define-properties: 1.2.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 functions-have-names: 1.2.3 - hasown: 2.0.2 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 is-callable: 1.2.7 + is-document.all: 1.0.0 dev: true /functions-have-names@1.2.3: @@ -4630,8 +3642,8 @@ packages: engines: {node: 6.* || 8.* || >= 10.*} dev: false - /get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + /get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} dev: true @@ -4642,12 +3654,12 @@ packages: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 /get-nonce@1.0.1: @@ -4660,7 +3672,7 @@ packages: engines: {node: '>= 0.4'} dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 /get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} @@ -4741,27 +3753,20 @@ packages: dependencies: has-symbols: 1.1.0 - /hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - 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: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 - property-information: 7.1.0 + property-information: 7.2.0 vfile: 6.0.3 vfile-location: 5.0.3 web-namespaces: 2.0.1 @@ -4770,27 +3775,27 @@ packages: /hast-util-heading-rank@3.0.0: resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 dev: false /hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 dev: false /hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 dev: false /hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.4.0 hast-util-from-parse5: 8.0.3 hast-util-to-parse5: 8.0.1 html-void-elements: 3.0.0 @@ -4806,16 +3811,16 @@ packages: /hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.4.0 unist-util-position: 5.0.0 dev: false /hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -4824,7 +3829,7 @@ packages: mdast-util-mdx-expression: 2.0.1 mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 unist-util-position: 5.0.0 @@ -4836,10 +3841,10 @@ packages: /hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -4848,13 +3853,13 @@ packages: /hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 dev: false /hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 @@ -4863,16 +3868,16 @@ packages: /hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 dev: false /hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 dev: false @@ -4880,8 +3885,13 @@ packages: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false - /highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + /highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + dev: false + + /highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} dev: false @@ -4889,8 +3899,8 @@ packages: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} dev: false - /html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + /html-parse-stringify@3.1.0: + resolution: {integrity: sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==} dependencies: void-elements: 3.1.0 dev: false @@ -4918,21 +3928,21 @@ packages: engines: {node: '>=16.17.0'} dev: true - /i18next-browser-languagedetector@8.2.0: - resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==} + /i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.10 dev: false - /i18next@25.8.0(typescript@5.9.3): - resolution: {integrity: sha512-urrg4HMFFMQZ2bbKRK7IZ8/CTE7D8H4JRlAwqA2ZwDRFfdd0K/4cdbNNLgfn9mo+I/h9wJu61qJzH7jCFAhUZQ==} + /i18next@25.10.10(typescript@5.9.3): + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} peerDependencies: - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: typescript: optional: true dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.10 typescript: 5.9.3 dev: false @@ -4941,8 +3951,8 @@ packages: engines: {node: '>= 4'} dev: true - /ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + /ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} dev: true @@ -4963,8 +3973,8 @@ packages: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} dev: false - /input-otp@1.4.2(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + /input-otp@1.5.0(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc @@ -4978,8 +3988,8 @@ packages: engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 dev: true /internmap@2.0.3: @@ -5002,7 +4012,7 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 dev: true @@ -5038,11 +4048,11 @@ packages: engines: {node: '>= 0.4'} dev: true - /is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + /is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} dependencies: - hasown: 2.0.2 + hasown: 2.0.4 dev: true /is-data-view@1.0.2: @@ -5066,6 +4076,13 @@ packages: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} dev: false + /is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: true + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5092,7 +4109,7 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.7.0 dev: true /is-generator-function@1.1.2: @@ -5152,7 +4169,7 @@ packages: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 dev: true /is-set@2.0.3: @@ -5193,7 +4210,7 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} dependencies: - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 dev: true /is-weakmap@2.0.2: @@ -5229,23 +4246,23 @@ packages: engines: {node: '>= 0.4'} dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 dev: true - /jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + /jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true dev: false /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - /js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + /js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true dependencies: argparse: 2.0.1 @@ -5267,7 +4284,7 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 @@ -5287,15 +4304,6 @@ packages: type-check: 0.4.0 dev: true - /lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: false - optional: true - /lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -5305,11 +4313,11 @@ packages: dev: false optional: true - /lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + /lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] - os: [darwin] + os: [android] requiresBuild: true dev: false optional: true @@ -5323,10 +4331,10 @@ packages: dev: false optional: true - /lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + /lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} - cpu: [x64] + cpu: [arm64] os: [darwin] requiresBuild: true dev: false @@ -5341,11 +4349,11 @@ packages: dev: false optional: true - /lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + /lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] - os: [freebsd] + os: [darwin] requiresBuild: true dev: false optional: true @@ -5359,11 +4367,11 @@ packages: dev: false optional: true - /lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + /lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] + cpu: [x64] + os: [freebsd] requiresBuild: true dev: false optional: true @@ -5377,12 +4385,11 @@ packages: dev: false optional: true - /lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + /lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} - cpu: [arm64] + cpu: [arm] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true @@ -5392,17 +4399,15 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + /lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true @@ -5412,17 +4417,15 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + /lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} - cpu: [x64] + cpu: [arm64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true @@ -5432,17 +4435,15 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] requiresBuild: true dev: false optional: true - /lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + /lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true @@ -5452,16 +4453,15 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] requiresBuild: true dev: false optional: true - /lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + /lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] + cpu: [x64] + os: [linux] requiresBuild: true dev: false optional: true @@ -5475,10 +4475,10 @@ packages: dev: false optional: true - /lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + /lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} - cpu: [x64] + cpu: [arm64] os: [win32] requiresBuild: true dev: false @@ -5493,24 +4493,14 @@ packages: dev: false optional: true - /lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + /lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 + cpu: [x64] + os: [win32] + requiresBuild: true dev: false + optional: true /lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} @@ -5531,6 +4521,25 @@ packages: lightningcss-win32-x64-msvc: 1.32.0 dev: false + /lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + dev: false + /lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -5548,9 +4557,9 @@ packages: lilconfig: 3.1.3 listr2: 8.3.3 micromatch: 4.0.8 - pidtree: 0.6.0 + pidtree: 0.6.1 string-argv: 0.3.2 - yaml: 2.8.2 + yaml: 2.9.1 transitivePeerDependencies: - supports-color dev: true @@ -5593,10 +4602,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} dependencies: - ansi-escapes: 7.2.0 + ansi-escapes: 7.3.0 cli-cursor: 5.0.0 slice-ansi: 7.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrap-ansi: 9.0.2 dev: true @@ -5620,9 +4629,9 @@ packages: /lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 devlop: 1.1.0 - highlight.js: 11.11.1 + highlight.js: 11.11.2 dev: false /lucide-react@0.507.0(react@19.2.1): @@ -5636,7 +4645,7 @@ packages: /magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 dev: false /markdown-table@3.0.4: @@ -5647,8 +4656,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - /mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + /mdast-util-find-and-replace@3.0.3: + resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==} dependencies: '@types/mdast': 4.0.4 escape-string-regexp: 5.0.0 @@ -5656,20 +4665,20 @@ packages: unist-util-visit-parents: 6.0.2 dev: false - /mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + /mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.3 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 unist-util-stringify-position: 4.0.0 transitivePeerDependencies: - supports-color @@ -5681,7 +4690,7 @@ packages: '@types/mdast': 4.0.4 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 + mdast-util-find-and-replace: 3.0.3 micromark-util-character: 2.1.1 dev: false @@ -5690,19 +4699,19 @@ packages: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color dev: false - /mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + /mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5713,8 +4722,8 @@ packages: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5724,8 +4733,8 @@ packages: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5733,13 +4742,13 @@ packages: /mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.1.0 mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.1 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5748,11 +4757,11 @@ packages: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5761,13 +4770,13 @@ packages: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 parse-entities: 4.0.2 stringify-entities: 4.0.4 unist-util-stringify-position: 4.0.0 @@ -5780,11 +4789,11 @@ packages: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color dev: false @@ -5799,9 +4808,9 @@ packages: /mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.4.0 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -5810,16 +4819,18 @@ packages: vfile: 6.0.3 dev: false - /mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + /mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 longest-streak: 3.1.0 mdast-util-phrasing: 4.1.0 mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 micromark-util-classify-character: 2.0.1 micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 unist-util-visit: 5.1.0 zwitch: 2.0.4 dev: false @@ -5834,25 +4845,26 @@ packages: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: true - /micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + /micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} dependencies: decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-factory-destination: 2.0.1 micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-factory-title: 2.0.1 micromark-factory-whitespace: 2.0.1 micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 micromark-util-html-tag-name: 2.0.1 micromark-util-normalize-identifier: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm-autolink-literal@2.1.0: @@ -5861,20 +4873,20 @@ packages: micromark-util-character: 2.1.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm-footnote@2.1.0: resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} dependencies: devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-normalize-identifier: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm-strikethrough@2.1.0: @@ -5885,33 +4897,33 @@ packages: micromark-util-classify-character: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false - /micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + /micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} dependencies: devlop: 1.1.0 - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm-tagfilter@2.0.0: resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm-task-list-item@2.1.0: resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} dependencies: devlop: 1.1.0 - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-extension-gfm@3.0.0: @@ -5920,11 +4932,11 @@ packages: micromark-extension-gfm-autolink-literal: 2.1.0 micromark-extension-gfm-footnote: 2.1.0 micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-table: 2.1.2 micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-factory-destination@2.0.1: @@ -5932,7 +4944,7 @@ packages: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-factory-label@2.0.1: @@ -5941,39 +4953,39 @@ packages: devlop: 1.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false - /micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + /micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} dependencies: micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-factory-title@2.0.1: resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} dependencies: - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-factory-whitespace@2.0.1: resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} dependencies: - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} dependencies: micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-chunked@2.0.1: @@ -5987,14 +4999,14 @@ packages: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-combine-extensions@2.0.1: resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} dependencies: micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-decode-numeric-character-reference@2.0.2: @@ -6012,6 +5024,12 @@ packages: micromark-util-symbol: 2.0.1 dev: false + /micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + dependencies: + micromark-util-types: 2.0.3 + dev: false + /micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} dev: false @@ -6029,7 +5047,7 @@ packages: /micromark-util-resolve-all@2.0.1: resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-sanitize-uri@2.0.1: @@ -6046,37 +5064,38 @@ packages: devlop: 1.1.0 micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 dev: false /micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} dev: false - /micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + /micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} dev: false - /micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + /micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 micromark-util-combine-extensions: 2.0.1 micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 micromark-util-encode: 2.0.1 micromark-util-normalize-identifier: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 transitivePeerDependencies: - supports-color dev: false @@ -6111,24 +5130,30 @@ packages: engines: {node: '>=18'} dev: true + /minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + dependencies: + brace-expansion: 5.0.12 + dev: true + /minimatch@3.1.3: resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==} dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 dev: true - /minimatch@9.0.7: - resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} - engines: {node: '>=16 || 14 >=14.17'} + /minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} dependencies: - brace-expansion: 5.0.6 + brace-expansion: 1.1.21 dev: true /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + /nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true dev: false @@ -6137,8 +5162,8 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + /node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} dependencies: array.prototype.flatmap: 1.3.3 @@ -6172,10 +5197,10 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 dev: true @@ -6184,30 +5209,30 @@ packages: resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 dev: true /object.fromentries@2.0.8: resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 dev: true /object.values@1.2.1: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 dev: true /onetime@6.0.0: @@ -6236,10 +5261,11 @@ packages: word-wrap: 1.2.5 dev: true - /own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + /own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 @@ -6330,30 +5356,28 @@ packages: engines: {node: '>=8.6'} dev: true - /picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + /picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - /pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + /pidtree@0.6.1: + resolution: {integrity: sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==} engines: {node: '>=0.10'} hasBin: true dev: true - /playwright-core@1.61.0: - resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} - engines: {node: '>=18'} + /playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} hasBin: true dev: true - /playwright@1.61.0: - resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} - engines: {node: '>=18'} + /playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} hasBin: true dependencies: - playwright-core: 1.61.0 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.0 dev: true /pngjs@5.0.0: @@ -6366,13 +5390,13 @@ packages: engines: {node: '>= 0.4'} dev: true - /postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + /postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.12 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 dev: false /prelude-ls@1.2.1: @@ -6387,8 +5411,8 @@ packages: fast-diff: 1.3.0 dev: true - /prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + /prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true dev: true @@ -6405,8 +5429,8 @@ packages: object-assign: 4.1.1 react-is: 16.13.1 - /property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + /property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} dev: false /proxy-from-env@2.1.0: @@ -6429,8 +5453,8 @@ packages: yargs: 15.4.1 dev: false - /radix-ui@1.6.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==} + /radix-ui@1.7.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-x+z5TV2v6u0QpAQ4dQfNMcvtIdSQ1Op2oqBEyfDXjNldRZbMTP+hcjr5Iie31bep81Axeioa+70Ww7pZDzTEQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6443,62 +5467,62 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1) - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) + '@radix-ui/react-accessible-icon': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-accordion': 1.2.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-alert-dialog': 1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-arrow': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-aspect-ratio': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-avatar': 1.2.7(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-checkbox': 1.3.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collapsible': 1.1.21(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-context-menu': 2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-dialog': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-direction': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-dropdown-menu': 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-form': 0.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-hover-card': 1.1.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-label': 2.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-menubar': 1.1.25(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-navigation-menu': 1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-one-time-password-field': 0.1.17(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-password-toggle-field': 0.1.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popover': 1.2.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-progress': 1.1.17(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-radio-group': 1.4.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-scroll-area': 1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-select': 2.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-separator': 1.1.16(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slider': 1.5.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-slot': 1.4.0(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-switch': 1.3.8(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-tabs': 1.1.22(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toast': 1.2.24(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle': 1.1.19(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toggle-group': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-toolbar': 1.1.20(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-tooltip': 1.3.0(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.2.18)(react@19.2.1) + '@radix-ui/react-visually-hidden': 1.2.12(@types/react-dom@19.2.7)(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false @@ -6512,16 +5536,21 @@ packages: scheduler: 0.27.0 dev: false - /react-hook-form@7.71.1(react@19.2.1): - resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==} + /react-hook-form@7.90.0(@types/react@19.2.18)(react@19.2.1): + resolution: {integrity: sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==} engines: {node: '>=18.0.0'} peerDependencies: + '@types/react': '*' react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true dependencies: + '@types/react': 19.2.18 react: 19.2.1 dev: false - /react-i18next@15.7.4(i18next@25.8.0)(react-dom@19.2.1)(react@19.2.1)(typescript@5.9.3): + /react-i18next@15.7.4(i18next@25.10.10)(react-dom@19.2.1)(react@19.2.1)(typescript@5.9.3): resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} peerDependencies: i18next: '>= 23.4.0' @@ -6537,9 +5566,9 @@ packages: typescript: optional: true dependencies: - '@babel/runtime': 7.28.6 - html-parse-stringify: 3.0.1 - i18next: 25.8.0(typescript@5.9.3) + '@babel/runtime': 7.29.10 + html-parse-stringify: 3.1.0 + i18next: 25.10.10(typescript@5.9.3) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) typescript: 5.9.3 @@ -6552,15 +5581,15 @@ packages: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} dev: false - /react-markdown@10.1.0(@types/react@19.2.10)(react@19.2.1): + /react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: '@types/react': '>=18' react: '>=18' dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 - '@types/react': 19.2.10 + '@types/react': 19.2.18 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 @@ -6585,7 +5614,7 @@ packages: react-dom: 19.2.1(react@19.2.1) dev: false - /react-remove-scroll-bar@2.3.8(@types/react@19.2.10)(react@19.2.1): + /react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} peerDependencies: @@ -6595,13 +5624,13 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 - react-style-singleton: 2.2.3(@types/react@19.2.10)(react@19.2.1) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.1) tslib: 2.8.1 dev: false - /react-remove-scroll@2.7.2(@types/react@19.2.10)(react@19.2.1): + /react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} engines: {node: '>=10'} peerDependencies: @@ -6611,17 +5640,17 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.10)(react@19.2.1) - react-style-singleton: 2.2.3(@types/react@19.2.10)(react@19.2.1) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.1) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.1) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.10)(react@19.2.1) - use-sidecar: 1.1.3(@types/react@19.2.10)(react@19.2.1) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.1) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.1) dev: false - /react-router-dom@7.17.0(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} + /react-router-dom@7.18.4(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -6629,11 +5658,11 @@ packages: dependencies: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - react-router: 7.17.0(react-dom@19.2.1)(react@19.2.1) + react-router: 7.18.4(react-dom@19.2.1)(react@19.2.1) dev: false - /react-router@7.17.0(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} + /react-router@7.18.4(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -6654,14 +5683,14 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 dependencies: - fast-equals: 5.4.0 + fast-equals: 5.4.4 prop-types: 15.8.1 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) react-transition-group: 4.4.5(react-dom@19.2.1)(react@19.2.1) dev: false - /react-style-singleton@2.2.3(@types/react@19.2.10)(react@19.2.1): + /react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} peerDependencies: @@ -6671,19 +5700,19 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 get-nonce: 1.0.1 react: 19.2.1 tslib: 2.8.1 dev: false - /react-syntax-highlighter@16.1.0(react@19.2.1): - resolution: {integrity: sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==} + /react-syntax-highlighter@16.1.2(react@19.2.1): + resolution: {integrity: sha512-4dM0+mYtfnhgwYWAvFx56fgyjIYk2yFopfiICFvMDhRfeSGRNd+ngFALdPD9q7llyGAmiE6pzOSOwI8Lf9ca1w==} engines: {node: '>= 16.20.2'} peerDependencies: react: '>= 0.14.0' dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.10 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 @@ -6698,7 +5727,7 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.10 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -6720,6 +5749,7 @@ packages: /recharts@2.15.4(react-dom@19.2.1)(react@19.2.1): resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6740,11 +5770,11 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 @@ -6753,8 +5783,8 @@ packages: /refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} dependencies: - '@types/hast': 3.0.4 - '@types/prismjs': 1.26.5 + '@types/hast': 3.0.5 + '@types/prismjs': 1.26.6 hastscript: 9.0.1 parse-entities: 4.0.2 dev: false @@ -6763,7 +5793,7 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -6774,8 +5804,8 @@ packages: /rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 + '@types/hast': 3.0.5 + '@ungap/structured-clone': 1.4.0 hast-util-heading-rank: 3.0.0 hast-util-is-element: 3.0.0 unified: 11.0.5 @@ -6785,7 +5815,7 @@ packages: /rehype-highlight@7.0.2: resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-text: 4.0.2 lowlight: 3.3.0 unist-util-visit: 5.1.0 @@ -6795,7 +5825,7 @@ packages: /rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 dev: false @@ -6803,14 +5833,14 @@ packages: /rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-sanitize: 5.0.2 dev: false /rehype-slug@6.0.0: resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 github-slugger: 2.0.0 hast-util-heading-rank: 3.0.0 hast-util-to-string: 3.0.1 @@ -6834,8 +5864,8 @@ packages: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.2 + mdast-util-from-markdown: 2.1.0 + micromark-util-types: 2.0.3 unified: 11.0.5 transitivePeerDependencies: - supports-color @@ -6844,7 +5874,7 @@ packages: /remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -6855,7 +5885,7 @@ packages: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} dependencies: '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 + mdast-util-to-markdown: 2.2.0 unified: 11.0.5 dev: false @@ -6873,14 +5903,14 @@ packages: engines: {node: '>=4'} dev: true - /resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + /resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 - node-exports-info: 1.6.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -6898,36 +5928,36 @@ packages: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} dev: true - /rolldown@1.0.3: - resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + /rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true dependencies: - '@oxc-project/types': 0.133.0 + '@oxc-project/types': 0.153.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@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 + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 dev: false - /safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + /safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -6960,8 +5990,8 @@ packages: hasBin: true dev: true - /semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + /semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true dev: true @@ -7002,7 +6032,7 @@ packages: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 dev: true /shebang-command@2.0.0: @@ -7017,8 +6047,8 @@ packages: engines: {node: '>=8'} dev: true - /side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + /side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 @@ -7046,13 +6076,13 @@ packages: side-channel-map: 1.0.1 dev: true - /side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + /side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 dev: true @@ -7078,18 +6108,23 @@ packages: is-fullwidth-code-point: 5.1.0 dev: true - /sonner@2.0.7(react-dom@19.2.1)(react@19.2.1): - resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + /sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.1)(react@19.2.1): + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true dependencies: + '@types/react': 19.2.18 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) dev: false - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + /source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} dev: false @@ -7124,66 +6159,67 @@ packages: engines: {node: '>=18'} dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 dev: true - /string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + /string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 dev: true /string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} dependencies: define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 dev: true - /string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + /string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + 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.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 dev: true - /string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + /string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 dev: true /string.prototype.trimstart@1.0.8: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 dev: true /stringify-entities@4.0.4: @@ -7200,11 +6236,11 @@ packages: ansi-regex: 5.0.1 dev: false - /strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + /strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.4.0 dev: true /strip-final-newline@3.0.0: @@ -7241,23 +6277,23 @@ packages: engines: {node: '>= 0.4'} dev: true - /synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + /synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} dependencies: - '@pkgr/core': 0.2.9 + '@pkgr/core': 0.3.6 dev: true - /tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + /tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} dev: false - /tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + /tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} dev: false - /tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + /tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} dev: false @@ -7265,21 +6301,12 @@ packages: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} dev: false - /tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - 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 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -7296,8 +6323,8 @@ packages: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} dev: false - /ts-api-utils@2.4.0(typescript@5.9.3): - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + /ts-api-utils@2.5.0(typescript@5.9.3): + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -7333,31 +6360,30 @@ packages: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 dev: true - /typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + /typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} engines: {node: '>= 0.4'} dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 - has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 dev: true - /typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + /typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 @@ -7365,18 +6391,18 @@ packages: reflect.getprototypeof: 1.0.10 dev: true - /typescript-eslint@8.54.0(eslint@9.39.2)(typescript@5.9.3): - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + /typescript-eslint@8.71.1(eslint@9.39.5)(typescript@5.9.3): + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0)(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3) - eslint: 9.39.2 + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1)(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.1(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7458,7 +6484,7 @@ packages: punycode: 2.3.1 dev: true - /use-callback-ref@1.3.3(@types/react@19.2.10)(react@19.2.1): + /use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} peerDependencies: @@ -7468,12 +6494,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 react: 19.2.1 tslib: 2.8.1 dev: false - /use-sidecar@1.1.3(@types/react@19.2.10)(react@19.2.1): + /use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.1): resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} peerDependencies: @@ -7483,20 +6509,12 @@ packages: '@types/react': optional: true dependencies: - '@types/react': 19.2.10 + '@types/react': 19.2.18 detect-node-es: 1.1.0 react: 19.2.1 tslib: 2.8.1 dev: false - /use-sync-external-store@1.6.0(react@19.2.1): - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dependencies: - react: 19.2.1 - dev: false - /uuidjs@5.1.0: resolution: {integrity: sha512-HAQPtUkr7t5Ud3uCwRcqtBRNagu/2aerrrBQE6PzgSluGijvFF75UaOq22Xw545GGviRjSLhc4c8CaSMI5h4Ng==} hasBin: true @@ -7530,7 +6548,7 @@ packages: '@types/d3-ease': 3.0.2 '@types/d3-interpolate': 3.0.4 '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.8 + '@types/d3-shape': 3.2.0 '@types/d3-time': 3.0.4 '@types/d3-timer': 3.0.2 d3-array: 3.2.4 @@ -7542,13 +6560,13 @@ packages: d3-timer: 3.0.1 dev: false - /vite@8.0.16(@types/node@20.19.30): - resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + /vite@8.3.4(@types/node@20.19.43): + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 + '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -7585,11 +6603,11 @@ packages: yaml: optional: true dependencies: - '@types/node': 20.19.30 - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 + '@types/node': 20.19.43 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 @@ -7620,7 +6638,7 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bound: 1.0.4 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -7631,7 +6649,7 @@ packages: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.20 + which-typed-array: 1.1.24 dev: true /which-collection@1.0.2: @@ -7648,12 +6666,12 @@ packages: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} dev: false - /which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + /which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -7689,15 +6707,15 @@ packages: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 dev: true /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} dev: false - /yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + /yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true dev: true diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index fd426046a..514750725 100755 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -88,7 +88,11 @@ function extractKeys(filePath) { } function main() { - const files = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.ts')); + // Shared translation modules (for example bot-setup.ts) are not locales. + // Match language-region and language-script filenames used by the app. + const files = readdirSync(LOCALES_DIR).filter((f) => + /^[a-z]{2,3}-(?:[A-Z]{2}|[A-Z][a-z]{3})\.ts$/.test(f), + ); if (!files.includes(REFERENCE)) { console.error(`Reference file ${REFERENCE} not found in ${LOCALES_DIR}`); diff --git a/web/src/app/RootLayout.tsx b/web/src/app/RootLayout.tsx index 3a5dffb12..444de4b4a 100644 --- a/web/src/app/RootLayout.tsx +++ b/web/src/app/RootLayout.tsx @@ -1,15 +1,9 @@ import { Outlet } from 'react-router-dom'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { BetaBanner } from '@/components/BetaBanner'; // 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 ( - <> - - - - ); + return ; } diff --git a/web/src/app/global.css b/web/src/app/global.css index a1be44406..7458cdc84 100644 --- a/web/src/app/global.css +++ b/web/src/app/global.css @@ -195,3 +195,28 @@ body:has([data-tour-modal='true']) [data-tour-modal='false'] { display: none; } + +/* Reserve native scrollbar space before content overflows. Extending the + utilities also covers responsive variants, nested panels and popup menus. + Horizontal-only scrollers and custom overlay scroll areas stay unchanged. */ +@utility overflow-auto { + scrollbar-gutter: stable; +} + +@utility overflow-y-auto { + scrollbar-gutter: stable; +} + +@utility overflow-scroll { + scrollbar-gutter: stable; +} + +@utility overflow-y-scroll { + scrollbar-gutter: stable; +} + +@layer base { + textarea { + scrollbar-gutter: stable; + } +} diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 85184b337..a371b3ef5 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -1,11 +1,11 @@ import EntityLoadState from '@/components/EntityLoadState'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { AlertTriangle, Trash2 } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { useCurrentWorkspace } from '@/app/infra/http'; +import { useCurrentWorkspace, userInfo } from '@/app/infra/http'; import { Agent, AgentPlatformTool } from '@/app/infra/entities/api'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench'; @@ -33,6 +33,9 @@ import AgentFormComponent, { RunnerStatus, } from './components/AgentFormComponent'; +// Keep the last checked display state across navigation within this app session. +const runnerStatusCache = new Map(); + export default function AgentDetailContent({ id, pipelineRevision, @@ -42,7 +45,7 @@ export default function AgentDetailContent({ }) { const isCreateMode = id === 'new'; const navigate = useNavigate(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); const canManage = currentWorkspace?.permissions.includes('resource.manage') ?? false; @@ -59,7 +62,43 @@ export default function AgentDetailContent({ const [basicInfoOpen, setBasicInfoOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleting, setDeleting] = useState(false); - const [runnerStatus, setRunnerStatus] = useState(null); + const runnerStatusKey = JSON.stringify([ + userInfo?.account_uuid, + currentWorkspace?.workspace.uuid, + id, + i18n.language, + ]); + const [runnerStatusState, setRunnerStatusState] = useState<{ + key: string; + status: RunnerStatus; + } | null>(null); + const runnerStatus = + runnerStatusState?.key === runnerStatusKey + ? runnerStatusState.status + : (runnerStatusCache.get(runnerStatusKey) ?? null); + const updateRunnerStatus = useCallback( + (status: RunnerStatus) => { + setRunnerStatusState((previous) => { + if (status.tone === 'neutral') { + const cached = + previous?.key === runnerStatusKey + ? previous.status + : runnerStatusCache.get(runnerStatusKey); + if (cached) return { key: runnerStatusKey, status: cached }; + } + return { key: runnerStatusKey, status }; + }); + if (status.tone !== 'neutral' && !formDirty) { + runnerStatusCache.delete(runnerStatusKey); + runnerStatusCache.set(runnerStatusKey, status); + if (runnerStatusCache.size > 100) { + const oldest = runnerStatusCache.keys().next().value; + if (oldest !== undefined) runnerStatusCache.delete(oldest); + } + } + }, + [runnerStatusKey, formDirty], + ); const [availableEventTypes, setAvailableEventTypes] = useState([ 'message.received', ]); @@ -79,10 +118,6 @@ export default function AgentDetailContent({ return () => setDetailEntityName(null); }, [id, isCreateMode, pipelines, setDetailEntityName, t]); - useEffect(() => { - setRunnerStatus(null); - }, [id]); - useEffect(() => { if (isCreateMode) return; let cancelled = false; @@ -258,7 +293,7 @@ export default function AgentDetailContent({ }} onDirtyChange={setFormDirty} onSavingChange={setFormSaving} - onRunnerStatusChange={setRunnerStatus} + onRunnerStatusChange={updateRunnerStatus} onSupportedEventPatternsChange={setSupportedEventPatterns} onPlatformToolsChange={setPlatformTools} guideEnabled={canManage} diff --git a/web/src/app/home/agents/PluginProcessorDetailContent.tsx b/web/src/app/home/agents/PluginProcessorDetailContent.tsx index 53e08f708..938809088 100644 --- a/web/src/app/home/agents/PluginProcessorDetailContent.tsx +++ b/web/src/app/home/agents/PluginProcessorDetailContent.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useSearchParams } from 'react-router-dom'; import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups'; -import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react'; +import { RefreshCw, Trash2, Clock3, Puzzle } from 'lucide-react'; import isEqual from 'lodash/isEqual'; import { toast } from 'sonner'; import type { @@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import LoadErrorState from '@/components/LoadErrorState'; import { ScrollArea } from '@/components/ui/scroll-area'; import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench'; import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton'; @@ -29,6 +30,10 @@ import PluginProcessorTrace, { ProcessorPayload, } from './components/PluginProcessorTrace'; import ProcessorRunList from './components/ProcessorRunList'; +import { + processorRunDuration, + formatRunDuration, +} from './components/processor-run-timing'; import PluginProcessorSettings from './components/PluginProcessorSettings'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; @@ -52,9 +57,19 @@ export default function PluginProcessorDetailContent({ onSaved: () => void; }) { const { t } = useTranslation(); - const [searchParams] = useSearchParams(); - const [activeTab, setActiveTab] = useState( - searchParams.get('tab') === 'logs' ? 'logs' : 'config', + const [, setSearchParams] = useSearchParams(); + const setActiveTab = useCallback( + (tab: string) => { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set('tab', tab); + return next; + }, + { preventScrollReset: true }, + ); + }, + [setSearchParams], ); const [platformTools, setPlatformTools] = useState([]); const toolLabels = Object.fromEntries( @@ -74,7 +89,11 @@ export default function PluginProcessorDetailContent({ componentRef, parameters: initialParameters, }); + const [needsInitialSave, setNeedsInitialSave] = useState( + Boolean(agent.component_ref) && !agent.supported_event_patterns?.length, + ); const dirty = + needsInitialSave || componentRef !== savedConfig.componentRef || !isEqual(parameters, savedConfig.parameters); const [runs, setRuns] = useState([]); @@ -132,7 +151,7 @@ export default function PluginProcessorDetailContent({ : []), { id: 'logs', - target: '[data-guide="event-processor-form-tab-logs"]', + target: '[data-guide="event-processor-form-monitoring"]', title: t('guidedTour.pluginProcessor.logs.title'), description: t('guidedTour.pluginProcessor.logs.description'), onEnter: () => setActiveTab('logs'), @@ -149,7 +168,27 @@ export default function PluginProcessorDetailContent({ ] : []), ], - [t, available, hasParameters, canOperate], + [t, available, hasParameters, canOperate, setActiveTab], + ); + + const applyInstalledProcessor = useCallback( + (component: RunnerDescriptor) => { + setComponents((current) => [ + ...current.filter((item) => item.id !== component.id), + component, + ]); + setComponentRef(component.id); + setParameters( + Object.fromEntries( + (component.config_schema ?? []) + .filter((field) => field.default !== undefined) + .map((field) => [field.name, field.default]), + ), + ); + setActiveTab('config'); + validate.current = null; + }, + [setActiveTab], ); const load = useCallback(async () => { @@ -324,6 +363,7 @@ export default function PluginProcessorDetailContent({ toast.success(t('agents.saveSuccess')); onSaved(); setSavedConfig({ componentRef, parameters }); + setNeedsInitialSave(false); await load(); return true; } catch { @@ -335,20 +375,13 @@ export default function PluginProcessorDetailContent({ } const logsContent = ( -
-
{ - event.preventDefault(); - void save(); - }} - /> +
{failed && ( - - - {t('agents.eventProcessor.loadError')} - - + )}
@@ -356,98 +389,123 @@ export default function PluginProcessorDetailContent({ ({runs.length})
- {runs.length > 0 && ( - void openRun(run)} - footer={ - cursor !== null ? ( - - ) : undefined - } - /> - )} - -
- {!selected ? ( - - - {loading - ? t('common.loading') - : t('agents.eventProcessor.noRuns')} - - - - ) : ( - <> -
-

- {eventPatternLabel(selected.metadata.event_type ?? '', t)} -

-

- {new Date(selected.created_at * 1000).toLocaleString()} -

-
- - {t(`agents.eventProcessor.status_${selected.status}`, { - defaultValue: selected.status, - })} - - - {selected.metadata.delivery != null && ( - - )} - - {selected.status === 'failed' && selected.status_reason && ( - - - {selected.status_reason} - - - )} - {eventCursor !== null && ( +
+ {runs.length > 0 && ( + void openRun(run)} + footer={ + cursor !== null ? ( - )} - - )} -
- + ) : undefined + } + /> + )} + +
+ {!selected ? ( + + + {loading + ? t('common.loading') + : t('agents.eventProcessor.noRuns')} + + + + ) : ( + <> +
+
+

+ {eventPatternLabel(selected.metadata.event_type ?? '', t)} +

+

+ {new Date(selected.created_at * 1000).toLocaleString()} +

+
+
+ {processorRunDuration(selected) !== null && ( + + + {formatRunDuration(processorRunDuration(selected)!)} + + )} + + {t(`agents.eventProcessor.status_${selected.status}`, { + defaultValue: selected.status, + })} + +
+
+
+

+ {t('agents.eventProcessor.input')} +

+ + {selected.metadata.delivery != null && ( + + )} +
+
+

+ + {t('agents.eventProcessor.type')} ·{' '} + {t('agents.eventProcessor.logsTab')} +

+ +
+ {selected.status === 'failed' && selected.status_reason && ( + + + {selected.status_reason} + + + )} + {eventCursor !== null && ( + + )} + + )} +
+
+
); @@ -456,6 +514,13 @@ export default function PluginProcessorDetailContent({ return ( <> + { + event.preventDefault(); + void save(); + }} + /> + + {t('agents.eventProcessor.type')} + + } titleAction={ canManage ? : undefined } titleControls={ , - content: ( -
- {!component ? ( -

- {t('agents.eventProcessor.selectComponent')} -

- ) : component.config_schema.length === 0 ? ( -

- {t('agents.eventProcessor.noSettings')} -

- ) : ( -
- - setParameters(values as Record) - } - onValidate={(fn) => { - validate.current = fn; - }} - /> -
- )} -
- ), - }, - { - value: 'logs', - label: t('agents.eventProcessor.logsTab'), - icon: , - content: logsContent, - }, - ], + configIcon={ + + } + monitoring={{ + label: t('pipelines.monitoring.title'), + workbenchLabel: t('pipelines.monitoring.workbench'), + content: logsContent, }} + configContent={ +
+ {!component ? ( +

+ {t('agents.eventProcessor.selectComponent')} +

+ ) : component.config_schema.length === 0 ? ( +

+ {t('agents.eventProcessor.noSettings')} +

+ ) : ( +
+ + setParameters(values as Record) + } + onValidate={(fn) => { + validate.current = fn; + }} + /> +
+ )} +
+ } debugTitle={canOperate ? t('agents.debugTab') : undefined} debugDescription={t('agents.eventProcessor.debugNotice')} debugContent={ @@ -568,7 +635,6 @@ export default function PluginProcessorDetailContent({ hasUnsavedChanges={dirty} beforeRun={save} onRunFinished={() => { - setActiveTab('logs'); void refreshLatestRun(); }} supportedEventPatterns={component.supported_event_patterns} diff --git a/web/src/app/home/agents/components/AgentApiToolPicker.tsx b/web/src/app/home/agents/components/AgentApiToolPicker.tsx index 6b7032c53..ca32fe68a 100644 --- a/web/src/app/home/agents/components/AgentApiToolPicker.tsx +++ b/web/src/app/home/agents/components/AgentApiToolPicker.tsx @@ -206,6 +206,22 @@ export default function AgentApiToolPicker({ ? platformCatalogAvailable : hostCatalogAvailable; + const isSelected = (tool: ToolEntry) => + (tool.kind === 'platform' ? selectedPlatform : selectedHost).has(tool.name); + + const setGroup = (tools: ToolEntry[], checked: boolean) => { + const platform = new Set(platformValue); + const host = new Set(hostValue); + for (const tool of tools) { + const selection = tool.kind === 'platform' ? platform : host; + if (checked) selection.add(tool.name); + else selection.delete(tool.name); + } + if (tools.some((tool) => tool.kind === 'platform')) + onPlatformChange([...platform]); + if (tools.some((tool) => tool.kind === 'host')) onHostChange([...host]); + }; + return (
@@ -275,8 +291,24 @@ export default function AgentApiToolPicker({
{groupedEntries.map(([group, groupTools], groupIndex) => (
0 && 'border-t')}> -
- {groupLabel(group)} +
+ {groupLabel(group)} +
{groupTools.map((tool) => { @@ -287,8 +319,7 @@ export default function AgentApiToolPicker({ const expanded = expandedTool === tool.key; const parameterNames = Object.keys( (tool.parameters.properties as - | Record - | undefined) ?? {}, + Record | undefined) ?? {}, ); return (
diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index 9c5fcf852..c7d96248f 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -27,17 +27,55 @@ import { import { Input } from '@/components/ui/input'; import EmojiPicker from '@/components/ui/emoji-picker'; import ProcessorTypeDiagram from './ProcessorTypeDiagram'; +import RunnerSelect from './RunnerSelect'; +import type { GetAgentMetadataResponseData } from '@/app/infra/entities/api'; +import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; +import { extractI18nObject } from '@/i18n/I18nProvider'; export default function AgentCreateContent({ onCreated, + allowedKinds, + embedded = false, }: { onCreated: (agentId: string) => void; + allowedKinds?: AgentKind[]; + embedded?: boolean; }) { const { t } = useTranslation(); - const [kind, setKind] = useState('agent'); + const [kind, setKind] = useState(allowedKinds?.[0] ?? 'pipeline'); + const [metadata, setMetadata] = useState(); + const [runner, setRunner] = useState(''); + const [loading, setLoading] = useState(true); + const createdPipeline = useRef(null); + const loadMetadata = useCallback(async () => { + setLoading(true); + try { + setMetadata(await httpClient.getAgentMetadata()); + } catch { + toast.error(t('agents.createError')); + } finally { + setLoading(false); + } + }, [t]); + useEffect(() => { + void loadMetadata(); + }, [loadMetadata]); + const runnerOptions = + kind === 'event_processor' + ? (metadata?.event_processors ?? []).map((item) => ({ + name: item.id, + label: { en_US: item.id, zh_Hans: item.id, ...item.label }, + })) + : (metadata?.runner_config?.stages + .find((stage) => stage.name === 'runner') + ?.config.find((item) => item.name === 'id')?.options ?? []); + const selectorLabel = t( + kind === 'event_processor' + ? 'agents.selectProcessorPlugin' + : 'agents.selectAgentRunner', + ); const formSchema = z.object({ name: z.string().min(1, { message: t('agents.nameRequired') }), - description: z.string().optional(), emoji: z.string().optional(), }); type FormValues = z.infer; @@ -45,11 +83,25 @@ export default function AgentCreateContent({ resolver: zodResolver(formSchema), defaultValues: { name: '', - description: '', - emoji: '🤖', + emoji: '⚙️', }, }); + function selectRunner(value: string, name?: string) { + setRunner(value); + if (!form.getValues('name').trim()) { + const option = runnerOptions.find((item) => item.name === value); + const displayName = + name || (option ? extractI18nObject(option.label) : ''); + if (displayName) { + form.setValue('name', displayName, { + shouldDirty: true, + shouldValidate: true, + }); + } + } + } + function handleKindChange(nextKind: AgentKind) { const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : kind === 'event_processor' ? '🧩' : '🤖'; @@ -59,6 +111,9 @@ export default function AgentCreateContent({ : nextKind === 'event_processor' ? '🧩' : '🤖'; + if ((kind === 'event_processor') !== (nextKind === 'event_processor')) { + setRunner(''); + } setKind(nextKind); const currentEmoji = form.getValues('emoji'); if (!currentEmoji || currentEmoji === previousDefaultEmoji) { @@ -67,28 +122,93 @@ export default function AgentCreateContent({ } async function handleSubmit(values: FormValues) { - return httpClient - .createAgent({ - kind, - name: values.name, - description: values.description ?? '', - emoji: - values.emoji || - (kind === 'pipeline' - ? '⚙️' - : kind === 'event_processor' - ? '🧩' - : '🤖'), - }) - .then((resp) => { + if (!runner) { + toast.error(selectorLabel); + return; + } + const runnerStage = metadata?.runner_config?.stages.find( + (stage) => stage.name === runner, + ); + const parameters = runnerStage ? getDefaultValues(runnerStage.config) : {}; + const config = { + runner: { id: runner }, + runner_config: { [runner]: parameters }, + }; + try { + if ( + kind === 'pipeline' && + runnerStage?.config.some((field) => + [ + 'llm-model-selector', + 'select-llm-model', + 'model-fallback-selector', + ].includes(field.type), + ) + ) { + const { uuid } = await httpClient.getDefaultModel(); + if (uuid) { + for (const field of runnerStage.config) { + if ( + ['llm-model-selector', 'select-llm-model'].includes(field.type) && + !parameters[field.name] + ) + parameters[field.name] = uuid; + if ( + field.type === 'model-fallback-selector' && + !parameters[field.name]?.primary + ) { + parameters[field.name] = { + ...parameters[field.name], + primary: uuid, + fallbacks: parameters[field.name]?.fallbacks ?? [], + }; + } + } + } + } + if (kind === 'pipeline') { + // Keep the created ID if configuration fails, so retry does not create duplicates. + const uuid = + createdPipeline.current ?? + ( + await httpClient.createAgent({ + kind, + name: values.name, + description: '', + emoji: values.emoji, + }) + ).uuid; + createdPipeline.current = uuid; + const { pipeline } = await httpClient.getPipeline(uuid); + await httpClient.updatePipeline(uuid, { + name: values.name, + emoji: values.emoji, + config: { + ...pipeline.config, + ai: { ...pipeline.config.ai, ...config }, + }, + }); toast.success(t('agents.createSuccess')); - onCreated(resp.uuid); - }) - .catch((err) => { - toast.error(t('agents.createError') + err.msg); - }); + onCreated(uuid); + } else { + const response = await httpClient.createAgent({ + kind, + name: values.name, + description: '', + emoji: values.emoji, + component_ref: runner, + ...(kind === 'event_processor' ? {} : { config }), + }); + toast.success(t('agents.createSuccess')); + onCreated(response.uuid); + } + } catch (error) { + toast.error( + t('agents.createError') + + String((error as { msg?: string }).msg ?? error), + ); + } } - const typeOptions = [ { kind: 'pipeline' as const, @@ -111,14 +231,22 @@ export default function AgentCreateContent({ ]; return (
-
-

- {t('agents.eventProcessor.createPageTitle')} -

+
+ {!embedded && ( +

+ {t('agents.eventProcessor.createPageTitle')} +

+ )} @@ -139,9 +267,11 @@ export default function AgentCreateContent({ > {t('agents.chooseType')} -

- {t('agents.chooseTypeDescription')} -

+ {!embedded && ( +

+ {t('agents.chooseTypeDescription')} +

+ )}
- {typeOptions.map((option) => { - const Icon = option.icon; - return ( - - - - - - - {option.title} - - - {option.description} - - - - ); - })} + {typeOptions + .filter( + (option) => + !allowedKinds || allowedKinds.includes(option.kind), + ) + .map((option) => { + const Icon = option.icon; + return ( +
+ + + + + + + {option.title} + + + {option.description} + + + +
+ ); + })}
@@ -185,7 +321,7 @@ export default function AgentCreateContent({ {t('agents.basicInfo')} - {t('agents.basicInfoDescription')} + {t('agents.createBasicInfoDescription')} @@ -195,6 +331,58 @@ export default function AgentCreateContent({ onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" > +
+

{selectorLabel}

+ { + setMetadata((previous) => + previous + ? { + ...previous, + runner_config: installed.configTab, + } + : previous, + ); + selectRunner( + installed.runner.name, + extractI18nObject(installed.runner.label), + ); + }} + onEventProcessorInstalled={(component) => { + setMetadata((previous) => + previous + ? { + ...previous, + event_processors: [ + ...( + previous.event_processors ?? [] + ).filter( + (item) => item.id !== component.id, + ), + component, + ], + } + : previous, + ); + selectRunner( + component.id, + extractI18nObject({ + en_US: component.id, + zh_Hans: component.id, + ...component.label, + }), + ); + }} + /> +
- - ( - - {t('common.description')} - - - - - - )} - />
- + diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 6f7f93014..cfb9656ea 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -1,3 +1,4 @@ +import { useSnapshotDirty } from '@/app/infra/hooks/useSnapshotDirty'; import EntityLoadState from '@/components/EntityLoadState'; import { forwardRef, @@ -9,7 +10,7 @@ import { useRef, useState, } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; @@ -157,7 +158,6 @@ function AgentFormComponent( const [activeSection, setActiveSection] = useState('runner'); const isSavingRef = useRef(false); - const hasUnsavedChangesRef = useRef(false); const loadedHostToolPolicyRef = useRef(undefined); const formSchema = z.object({ @@ -197,18 +197,20 @@ function AgentFormComponent( const savedSnapshotRef = useRef(''); const initializedStagesRef = useRef>(new Set()); - const watchedValues = form.watch(); - const hasUnsavedChanges = (() => { - if (!savedSnapshotRef.current) return false; - return JSON.stringify(watchedValues) !== savedSnapshotRef.current; - })(); - hasUnsavedChangesRef.current = hasUnsavedChanges; + const { + isDirty: hasUnsavedChanges, + dirtyRef: hasUnsavedChangesRef, + refreshDirty, + } = useSnapshotDirty(form, savedSnapshotRef); useEffect(() => { onDirtyChange?.(hasUnsavedChanges); }, [hasUnsavedChanges, onDirtyChange]); - const supportedEventPatterns = form.watch('supported_event_patterns'); + const supportedEventPatterns = useWatch({ + control: form.control, + name: 'supported_event_patterns', + }); useEffect(() => { onSupportedEventPatternsChange?.(supportedEventPatterns); }, [onSupportedEventPatternsChange, supportedEventPatterns]); @@ -279,6 +281,7 @@ function AgentFormComponent( }; form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); + refreshDirty(); initializedStagesRef.current.clear(); setInitialDataLoaded(true); }) @@ -290,7 +293,7 @@ function AgentFormComponent( return () => { cancelled = true; }; - }, [agentId, form, t, loadAttempt]); + }, [agentId, form, t, loadAttempt, refreshDirty]); useEffect(() => { if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) { @@ -340,7 +343,11 @@ function AgentFormComponent( void loadPluginSystemStatus(); }, [loadPluginSystemStatus]); - const currentRunner = (form.watch('runner') as Record)?.id; + const currentRunner = useWatch({ + control: form.control, + name: 'runner', + compute: (runner) => runner?.id, + }); const runnerOptions = useMemo(() => { const runnerStage = runnerConfigSchema?.stages.find( (stage) => stage.name === 'runner', @@ -358,24 +365,38 @@ function AgentFormComponent( const activeRunnerStage = runnerConfigSchema?.stages.find( (stage) => stage.name === currentRunner, ); - const runnerConfigValues = form.watch('runner_config') as Record< + const runnerConfigValues = form.getValues('runner_config') as Record< string, Record >; - const activeRunnerValues = useMemo( - () => runnerConfigValues?.[currentRunner] ?? {}, - [currentRunner, runnerConfigValues], - ); - const missingRunnerFields = useMemo( - () => - (activeRunnerStage?.config ?? []).filter( - (field) => - field.required && - isRunnerFieldVisible(field, activeRunnerValues) && - isRequiredRunnerValueMissing(activeRunnerValues[field.name]), - ), - [activeRunnerStage, activeRunnerValues], + useWatch({ + control: form.control, + compute: (values) => + (activeRunnerStage?.config ?? []) + .filter( + (field) => + field.required && + isRunnerFieldVisible( + field, + values.runner_config?.[currentRunner] ?? {}, + ) && + isRequiredRunnerValueMissing( + values.runner_config?.[currentRunner]?.[field.name], + ), + ) + .map((field) => field.name) + .join('\n'), + }); + const activeRunnerValues = runnerConfigValues?.[currentRunner] ?? {}; + const missingRunnerFields = (activeRunnerStage?.config ?? []).filter( + (field) => + field.required && + isRunnerFieldVisible(field, activeRunnerValues) && + isRequiredRunnerValueMissing(activeRunnerValues[field.name]), ); + const missingRunnerFieldNames = missingRunnerFields + .map((field) => extractI18nObject(field.label)) + .join(', '); const primarySections: Array<{ name: AgentConfigSection; label: string; @@ -448,9 +469,7 @@ function AgentFormComponent( return { label: t('agents.runnerConfigIncomplete'), description: t('agents.runnerConfigIncompleteDescription', { - fields: missingRunnerFields - .map((field) => extractI18nObject(field.label)) - .join(', '), + fields: missingRunnerFieldNames, }), tone: 'warning', }; @@ -471,7 +490,8 @@ function AgentFormComponent( pluginStatusLoading, pluginSystemStatus, runnerOptions.length, - missingRunnerFields, + missingRunnerFieldNames, + missingRunnerFields.length, selectedRunnerOption, t, ]); @@ -520,11 +540,12 @@ function AgentFormComponent( onRunnerStatusChange?.(runnerStatus); }, [onRunnerStatusChange, runnerStatus]); - function updateSnapshotIfInitial(stageKey: string) { + function updateSnapshotIfInitial(stageKey: string, wasDirty: boolean) { if (!initializedStagesRef.current.has(stageKey)) { initializedStagesRef.current.add(stageKey); - if (!hasUnsavedChanges) { + if (!wasDirty) { savedSnapshotRef.current = JSON.stringify(form.getValues()); + refreshDirty(); } } } @@ -534,9 +555,10 @@ function AgentFormComponent( stageName: string, values: object, ) { + const wasDirty = hasUnsavedChangesRef.current; if (formName === 'runner') { form.setValue('runner', values, { shouldDirty: true }); - updateSnapshotIfInitial(`runner.${stageName}`); + updateSnapshotIfInitial(`runner.${stageName}`, wasDirty); return; } @@ -550,7 +572,7 @@ function AgentFormComponent( }, { shouldDirty: true }, ); - updateSnapshotIfInitial(`runner_config.${stageName}`); + updateSnapshotIfInitial(`runner_config.${stageName}`, wasDirty); } function renderDynamicStage(stage: PipelineConfigStage) { @@ -558,8 +580,8 @@ function AgentFormComponent( if (!isRunnerSelector && stage.name !== currentRunner) return null; const initialValues = isRunnerSelector - ? (form.watch('runner') as Record) || {} - : ((form.watch('runner_config') as Record) || {})[ + ? (form.getValues('runner') as Record) || {} + : ((form.getValues('runner_config') as Record) || {})[ stage.name ] || {}; @@ -640,6 +662,7 @@ function AgentFormComponent( loadedHostToolPolicyRef.current = [...values.allowed_tools]; } savedSnapshotRef.current = submittedSnapshot; + refreshDirty(); onFinish(agent); toast.success(t('agents.saveSuccess')); return true; @@ -655,7 +678,14 @@ function AgentFormComponent( onSavingChange?.(false); } }, - [agentId, hostToolCatalogAvailable, onFinish, onSavingChange, t], + [ + agentId, + hostToolCatalogAvailable, + onFinish, + onSavingChange, + refreshDirty, + t, + ], ); function handleSubmit(values: FormValues) { @@ -682,6 +712,7 @@ function AgentFormComponent( emoji: values.emoji || '🤖', }; savedSnapshotRef.current = JSON.stringify(snapshot); + refreshDirty(); } }, async save() { @@ -693,7 +724,14 @@ function AgentFormComponent( return (await saveValues(form.getValues())) ?? false; }, }), - [form, initialDataLoaded, loadFailed, saveValues], + [ + form, + initialDataLoaded, + loadFailed, + saveValues, + hasUnsavedChangesRef, + refreshDirty, + ], ); if (loadFailed) diff --git a/web/src/app/home/agents/components/AgentMonitoringTab.tsx b/web/src/app/home/agents/components/AgentMonitoringTab.tsx index 34c712240..9a6a8063c 100644 --- a/web/src/app/home/agents/components/AgentMonitoringTab.tsx +++ b/web/src/app/home/agents/components/AgentMonitoringTab.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Brain, ChevronRight, RefreshCw, Wrench } from 'lucide-react'; +import { ChevronRight, RefreshCw } from 'lucide-react'; +import AgentRunTimeline, { isReplyStep, isMockStep } from './AgentRunTimeline'; import { httpClient } from '@/app/infra/http/HttpClient'; import type { AgentPlatformTool, @@ -13,6 +14,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import LoadErrorState from '@/components/LoadErrorState'; import { Collapsible, CollapsibleTrigger, @@ -206,7 +208,17 @@ export default function AgentMonitoringTab({ const labels = Object.fromEntries( platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]), ); - const steps = executionSteps(events); + const steps = executionSteps(events).filter( + (step) => step.kind === 'tool' || step.text.trim() || step.reasoning.trim(), + ); + const toolSteps = steps.filter((step) => step.kind === 'tool'); + const sentCount = toolSteps.filter( + (step) => + isReplyStep(step) && !isMockStep(step) && step.status === 'completed', + ).length; + const sendFailed = toolSteps.some( + (step) => isReplyStep(step) && step.status === 'failed', + ); const duration = selected ? processorRunDuration(selected) : null; const failureReason = selected?.status_reason || @@ -257,11 +269,13 @@ export default function AgentMonitoringTab({
{listError && ( - - {t('monitoring.loadError')} - + )} -
+
setSelectedId(run.run_id)} - className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b p-3 text-left font-normal last:border-b-0 aria-pressed:bg-accent" + className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b border-l-2 border-l-transparent p-4 text-left font-normal last:border-b-0 aria-pressed:border-l-primary aria-pressed:bg-primary/5" > {eventPatternLabel(run.metadata.event_type ?? '', t)} {debugRun(run) && ( - {t('agents.debugTab')} + {t('agents.debugTab')} )} {processorRunDuration(run) !== null && ( {formatRunDuration(processorRunDuration(run)!)} @@ -343,6 +358,7 @@ export default function AgentMonitoringTab({ {eventPatternLabel(selected.metadata.event_type ?? '', t)} {debugRun(selected) && ( - {t('agents.debugTab')} + {t('agents.debugTab')} )}
@@ -373,9 +389,37 @@ export default function AgentMonitoringTab({ )}
-

- {selected.runner_id} -

+
+
+
+

+ {t('agents.monitoring.runStatus')} +

+

+ {stateLabel(selected)} +

+
+
+

+ {t('agents.monitoring.toolCalls')} +

+

+ {toolSteps.length} +

+
+
+

+ {t('agents.monitoring.delivery')} +

+

+ {t( + `agents.monitoring.${sendFailed ? 'sendFailed' : sentCount ? 'sendCount' : 'noSend'}`, + { count: sentCount }, + )} +

+
{failedStatuses.has(selected.status) && failureReason && ( @@ -418,72 +462,22 @@ export default function AgentMonitoringTab({

{t('agents.monitoring.execution')} + + {steps.length} +

- {steps.map((step, index) => ( - - - - {step.kind === 'tool' ? ( - <> - - - {labels[step.name] || step.name} - - - {t( - `agents.debugTool${step.status === 'running' ? (activeStatuses.has(selected.status) || eventCursor !== null ? 'Running' : 'Interrupted') : step.result && typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true ? (step.status === 'failed' ? 'MockFailed' : 'Simulated') : step.status === 'failed' ? 'Failed' : 'Completed'}`, - )} - - - ) : ( - <> - - {t('agents.debugTextOutput')} - - )} - - - - {step.kind === 'message' ? ( - <> - {step.text && ( -

{step.text}

- )} - {step.reasoning && ( - - )} - - ) : ( - <> - {step.error && ( -

- {step.error} -

- )} - - {step.result !== undefined && ( - - )} - - )} -
-
- ))} + {!steps.length && ( +

+ {t('agents.monitoring.noSteps')} +

+ )} + {events .filter((event) => event.type === 'processor.log') .map((event) => ( @@ -520,6 +514,7 @@ export default function AgentMonitoringTab({ > {json({ run_id: selected.run_id, + runner_id: selected.runner_id, usage: selected.usage, events, })} @@ -530,17 +525,10 @@ export default function AgentMonitoringTab({ ) )} {traceError && ( - - - {t('monitoring.loadError')} - - - + setTraceRevision((value) => value + 1)} + /> )}
diff --git a/web/src/app/home/agents/components/AgentRunTimeline.tsx b/web/src/app/home/agents/components/AgentRunTimeline.tsx new file mode 100644 index 000000000..228140309 --- /dev/null +++ b/web/src/app/home/agents/components/AgentRunTimeline.tsx @@ -0,0 +1,174 @@ +import { useTranslation } from 'react-i18next'; +import { MessageSquare, Send, Wrench } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { ProcessorPayload } from './PluginProcessorTrace'; +import type { ExecutionStep } from './debug-execution'; +import { toolDisplay, toolSummary } from './tool-summary'; + +export function isReplyStep(step: ExecutionStep) { + return step.kind === 'tool' && toolDisplay(step.name)?.kind === 'reply'; +} + +export function isMockStep(step: ExecutionStep) { + return ( + step.kind === 'tool' && + !!step.result && + typeof step.result === 'object' && + 'mock' in step.result && + step.result.mock === true + ); +} + +export default function AgentRunTimeline({ + steps, + labels, + active, +}: { + steps: ExecutionStep[]; + labels: Record; + active: boolean; +}) { + const { t } = useTranslation(); + return ( +
    + {steps.map((step, index) => { + const reply = isReplyStep(step); + const mock = isMockStep(step); + const params = + step.kind === 'tool' && + step.parameters && + typeof step.parameters === 'object' + ? (step.parameters as Record) + : {}; + const summary = toolSummary( + step.kind === 'tool' ? step.name : '', + params, + ); + const replyText = reply ? summary.text : ''; + const actionText = reply ? '' : summary.text; + const Icon = + step.kind === 'message' ? MessageSquare : reply ? Send : Wrench; + const status = step.kind === 'tool' ? step.status : null; + const statusKey = + status === 'running' + ? active + ? 'Running' + : 'Interrupted' + : mock + ? status === 'failed' + ? 'MockFailed' + : 'Simulated' + : status === 'failed' + ? 'Failed' + : 'Completed'; + return ( +
  1. + {index < steps.length - 1 && ( +
  2. + ); + })} +
+ ); +} diff --git a/web/src/app/home/agents/components/PluginProcessorSettings.tsx b/web/src/app/home/agents/components/PluginProcessorSettings.tsx index d11fa822f..127152392 100644 --- a/web/src/app/home/agents/components/PluginProcessorSettings.tsx +++ b/web/src/app/home/agents/components/PluginProcessorSettings.tsx @@ -1,139 +1,50 @@ -import { Puzzle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; import type { RunnerDescriptor } from '@/app/infra/entities/api'; -import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon'; -import { extractI18nObject } from '@/i18n/I18nProvider'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; - -function ProcessorComponentContent({ - component, - option = false, -}: { - component: RunnerDescriptor; - option?: boolean; -}) { - const label = extractI18nObject({ - en_US: component.id, - zh_Hans: component.id, - ...component.label, - }); - const pluginId = `${component.plugin_author}/${component.plugin_name}`; - const iconURL = useInstalledPluginIcon( - component.plugin_author, - component.plugin_name, - ); - return ( - - {iconURL ? ( - - ) : ( - - )} - - {label} - - {option && ( - - {pluginId} - - )} - - ); -} +import RunnerSelect from './RunnerSelect'; export default function PluginProcessorSettings({ components, value, onChange, disabled = false, + installScope, + onInstalled, }: { components: RunnerDescriptor[]; value: string; onChange: (value: string) => void; disabled?: boolean; + installScope: string; + onInstalled: (component: RunnerDescriptor) => void; }) { const { t } = useTranslation(); - const selected = components.find((item) => item.id === value); + const options = components.map((component) => ({ + name: component.id, + label: { en_US: component.id, zh_Hans: component.id, ...component.label }, + })); + if (value && !components.some((component) => component.id === value)) + options.push({ + name: value, + label: { + en_US: t('agents.eventProcessor.unavailable'), + zh_Hans: t('agents.eventProcessor.unavailable'), + }, + }); return ( -
- - +
+
); } diff --git a/web/src/app/home/agents/components/PluginProcessorTrace.tsx b/web/src/app/home/agents/components/PluginProcessorTrace.tsx index 40eac2329..4ef9106de 100644 --- a/web/src/app/home/agents/components/PluginProcessorTrace.tsx +++ b/web/src/app/home/agents/components/PluginProcessorTrace.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronRight } from 'lucide-react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ChevronRight, ScrollText } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -47,38 +46,50 @@ export default function PluginProcessorTrace({ }) { const { t } = useTranslation(); return ( -
+
{events.map((event, index) => event.type === 'processor.log' ? ( - - - {String(event.data.level)} - - {String(event.data.text)} +
+ + + {t('agents.eventProcessor.logsTab')} - - + + {String(event.data.level)} + +
+

+ {String(event.data.text)} +

+
) : ( - - {t( - `agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`, - { defaultValue: event.type }, - )} - {typeof event.data.tool_name === 'string' && ( - - {toolLabels[event.data.tool_name] || event.data.tool_name} - - )} - - } - value={event.data} - /> + className="rounded-lg border bg-background p-3" + > + + {t( + `agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`, + { defaultValue: event.type }, + )} + {typeof event.data.tool_name === 'string' && ( + + {toolLabels[event.data.tool_name] || event.data.tool_name} + + )} + + } + value={event.data} + /> +
), )}
diff --git a/web/src/app/home/agents/components/ProcessorRunList.tsx b/web/src/app/home/agents/components/ProcessorRunList.tsx index c9abec543..01684426f 100644 --- a/web/src/app/home/agents/components/ProcessorRunList.tsx +++ b/web/src/app/home/agents/components/ProcessorRunList.tsx @@ -15,18 +15,22 @@ export default function ProcessorRunList({ selectedId, onSelect, footer, + className, }: { runs: ProcessorRun[]; selectedId?: string; onSelect: (run: ProcessorRun) => void; footer?: ReactNode; + className?: string; }) { const { t } = useTranslation(); return (
{runs.map((run) => { const duration = processorRunDuration(run); @@ -41,7 +45,7 @@ export default function ProcessorRunList({ onClick={() => onSelect(run)} aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`} title={event} - className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent" + className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b border-l-2 border-l-transparent p-4 text-left text-sm font-normal last:border-b-0 aria-pressed:border-l-primary aria-pressed:bg-primary/5" > {label} @@ -54,6 +58,7 @@ export default function ProcessorRunList({ ) : ( - + )} {extractI18nObject(option.label)} @@ -88,10 +94,13 @@ function InstalledRunnerContent({ function InstalledRunnerOptionContent({ option, description, + eventProcessor = false, }: { option: IDynamicFormItemOption; description: string; + eventProcessor?: boolean; }) { + const Icon = eventProcessor ? Puzzle : Bot; const parts = pluginOptionParts(option); const iconURL = useInstalledPluginIcon(parts?.author, parts?.name); @@ -104,7 +113,7 @@ function InstalledRunnerOptionContent({ className="row-span-2 size-7 shrink-0 rounded-md object-cover" /> ) : ( - + )} {extractI18nObject(option.label)} @@ -202,13 +211,19 @@ export default function RunnerSelect({ onValueChange, installScope, onInstalled, + usage = 'agent', + disabled = false, + onEventProcessorInstalled, }: { options: IDynamicFormItemOption[]; label: string; value: string; onValueChange: (value: string) => void; installScope: string; - onInstalled: (installed: InstalledRunner) => void; + onInstalled?: (installed: InstalledRunner) => void; + usage?: 'agent' | 'event'; + disabled?: boolean; + onEventProcessorInstalled?: (component: RunnerDescriptor) => void; }) { const { t } = useTranslation(); const { addTask, tasks } = usePluginInstallTasks(); @@ -230,7 +245,7 @@ export default function RunnerSelect({ setCatalogLoading(true); setCatalogError(false); try { - const catalog = await loadRunnerCatalog('agent'); + const catalog = await loadRunnerCatalog(usage); setMarketplaceRunners(catalog.marketplaceRunners); setInstalledPluginIds(catalog.installedPluginIds); setInstalledPluginDescriptions(catalog.installedPluginDescriptions); @@ -240,7 +255,37 @@ export default function RunnerSelect({ } finally { setCatalogLoading(false); } - }, []); + }, [usage]); + + useEffect(() => { + if ( + usage !== 'event' || + disabled || + !readPendingRunnerInstall(installScope) + ) + return; + let cancelled = false; + void resumePendingEventProcessorInstall(installScope) + .then((component) => { + if (!cancelled && component) { + onEventProcessorInstalled?.(component); + void loadCatalog(); + } + }) + .catch((error) => { + if (!cancelled) setInstallError(installErrorMessage(error, t)); + }); + return () => { + cancelled = true; + }; + }, [ + installScope, + usage, + disabled, + onEventProcessorInstalled, + loadCatalog, + t, + ]); useEffect(() => { void loadCatalog(); @@ -289,22 +334,28 @@ export default function RunnerSelect({ const handleInstall = useCallback( async (plugin: PluginV4) => { const pluginId = marketplacePluginId(plugin); - if (pendingInstall || installingPluginId) return; + if (disabled || pendingInstall || installingPluginId) return; setInstallingPluginId(pluginId); setInstallError(null); try { - const installed = await installMarketplaceRunner(plugin, { + const installOptions = { scope: installScope, - onTaskCreated: (taskId) => + onTaskCreated: (taskId: number) => addTask({ taskId, pluginName: marketplacePluginId(plugin), source: 'marketplace', extensionType: 'plugin', }), - }); - onInstalled(installed); + }; + if (usage === 'event') { + onEventProcessorInstalled?.( + await installMarketplaceEventProcessor(plugin, installOptions), + ); + } else { + onInstalled?.(await installMarketplaceRunner(plugin, installOptions)); + } await loadCatalog(); toast.success( t('agents.runnerInstallSuccess', { @@ -329,12 +380,16 @@ export default function RunnerSelect({ onInstalled, pendingInstall, t, + usage, + disabled, + onEventProcessorInstalled, ], ); return (
diff --git a/web/src/app/home/agents/components/debug-event-data.ts b/web/src/app/home/agents/components/debug-event-data.ts index fc96cb811..59c110f59 100644 --- a/web/src/app/home/agents/components/debug-event-data.ts +++ b/web/src/app/home/agents/components/debug-event-data.ts @@ -90,6 +90,90 @@ const DEBUG_EVENT_DEFINITIONS: Record = { messageField: 'text', }, 'message.deleted': deletion, + 'dingtalk.card_action': { + fields: [user], + defaults: { + space_id: 'dtv1.card//IM_GROUP.debug-group', + card_instance_id: 'debug-card', + action: 'confirm', + data: {}, + }, + }, + 'wecombot.enter_chat': { + fields: [user], + defaults: { + chat_id: 'debug-user', + chat_type: 'private', + message_id: 'debug-entry', + data: {}, + }, + }, + 'wecombot.template_card_event': { + fields: [user], + defaults: { + chat_id: 'debug-user', + chat_type: 'private', + message_id: 'debug-card-click', + task_id: 'debug-card', + event_key: 'confirm', + card_type: 'button_interaction', + selected_items: [], + data: {}, + }, + }, + 'wecomcs.enter_session': { + fields: [user], + defaults: { + open_kfid: 'debug-kf', + external_userid: 'debug-user', + chat_id: 'debug-user|debug-kf', + scene: 'debug', + scene_param: '', + welcome_code: '', + wechat_channels: {}, + }, + }, + 'wecomcs.msg_send_fail': { + fields: [user], + defaults: { + open_kfid: 'debug-kf', + external_userid: 'debug-user', + chat_id: 'debug-user|debug-kf', + fail_msgid: 'debug-message', + fail_type: 4, + }, + }, + 'wecomcs.servicer_status_change': { + fields: [], + defaults: { + open_kfid: 'debug-kf', + servicer_userid: 'debug-servicer', + status: 2, + stop_type: 0, + }, + }, + 'wecomcs.session_status_change': { + fields: [user], + defaults: { + open_kfid: 'debug-kf', + external_userid: 'debug-user', + chat_id: 'debug-user|debug-kf', + change_type: 2, + old_servicer_userid: 'debug-servicer-1', + new_servicer_userid: 'debug-servicer-2', + msg_code: '', + }, + }, + 'wecomcs.reject_customer_msg_switch_change': { + fields: [user], + defaults: { + open_kfid: 'debug-kf', + external_userid: 'debug-user', + chat_id: 'debug-user|debug-kf', + servicer_userid: 'debug-servicer', + reject_switch: 1, + }, + }, 'message.recalled': deletion, 'message.reaction': { fields: [ diff --git a/web/src/app/home/agents/components/tool-summary.ts b/web/src/app/home/agents/components/tool-summary.ts new file mode 100644 index 000000000..33c3d9b01 --- /dev/null +++ b/web/src/app/home/agents/components/tool-summary.ts @@ -0,0 +1,73 @@ +type ToolDisplay = { + fields: readonly string[]; + kind?: 'reply'; + recipient?: string; +}; + +// Explicit display declarations for built-in tools. Unknown tools have no +// summary: their parameters remain available in the payload disclosure. +const TOOL_DISPLAYS: Readonly> = { + exec: { fields: ['command'] }, + read: { fields: ['path'] }, + write: { fields: ['path'] }, + edit: { fields: ['path'] }, + glob: { fields: ['pattern', 'path'] }, + grep: { fields: ['pattern', 'path'] }, + event_reply: { fields: ['text'], kind: 'reply' }, + platform_send_message: { + fields: ['text'], + kind: 'reply', + recipient: 'target_id', + }, + event_delete_message: { fields: [] }, + event_get_actor: { fields: [] }, + event_get_group: { fields: [] }, + event_get_group_member: { fields: [] }, + event_mute_member: { fields: ['duration'] }, + event_unmute_member: { fields: [] }, + event_kick_member: { fields: [] }, + event_respond_friend_request: { fields: ['approve', 'remark'] }, + event_respond_group_invite: { fields: ['approve'] }, + platform_get_message: { fields: ['chat_id', 'message_id'] }, + platform_delete_message: { fields: ['chat_id', 'message_id'] }, + platform_get_group_info: { fields: ['group_id'] }, + platform_get_group_list: { fields: [] }, + platform_get_group_member_list: { fields: ['group_id'] }, + platform_get_group_member_info: { fields: ['group_id', 'user_id'] }, + platform_set_group_name: { fields: ['group_id', 'name'] }, + platform_mute_member: { fields: ['group_id', 'user_id', 'duration'] }, + platform_unmute_member: { fields: ['group_id', 'user_id'] }, + platform_kick_member: { fields: ['group_id', 'user_id'] }, + platform_leave_group: { fields: ['group_id'] }, + platform_get_user_info: { fields: ['user_id'] }, + platform_get_friend_list: { fields: [] }, +}; + +export function toolDisplay(name: string): ToolDisplay | undefined { + return Object.prototype.hasOwnProperty.call(TOOL_DISPLAYS, name) + ? TOOL_DISPLAYS[name] + : undefined; +} + +export function toolSummary(name: string, parameters: Record) { + const display = toolDisplay(name); + const fields = (display?.fields ?? []).flatMap((field) => { + const value = parameters[field]; + if (!['string', 'number', 'boolean'].includes(typeof value) || value === '') + return []; + return [{ field, text: String(value) }]; + }); + return { + text: fields + .map(({ field, text }) => + display?.kind === 'reply' || display?.fields.length === 1 + ? text + : `${field}: ${text}`, + ) + .join('\n'), + recipient: + display?.recipient && typeof parameters[display.recipient] === 'string' + ? String(parameters[display.recipient]) + : '', + }; +} diff --git a/web/src/app/home/agents/runner-marketplace.ts b/web/src/app/home/agents/runner-marketplace.ts index a2cb498f4..ebec6c3e9 100644 --- a/web/src/app/home/agents/runner-marketplace.ts +++ b/web/src/app/home/agents/runner-marketplace.ts @@ -8,7 +8,7 @@ import { type PluginV4, type RunnerUsage, } from '@/app/infra/entities/plugin'; -import type { AsyncTask } from '@/app/infra/entities/api'; +import type { AsyncTask, RunnerDescriptor } from '@/app/infra/entities/api'; import type { I18nObject } from '@/app/infra/entities/common'; export const RUNNER_COMPONENT_FILTER = 'Runner'; @@ -20,9 +20,7 @@ const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-runner-install'; const RUNNER_INSTALL_INTENT_EVENT = 'langbot-runner-install-change'; export type RunnerMarketplaceErrorCode = - | 'version-unavailable' - | 'install-timeout' - | 'registration-timeout'; + 'version-unavailable' | 'install-timeout' | 'registration-timeout'; export class RunnerMarketplaceError extends Error { constructor(public readonly code: RunnerMarketplaceErrorCode) { @@ -232,6 +230,14 @@ export async function installMarketplaceRunner( plugin: PluginV4, options: InstallRunnerOptions, ): Promise { + const pending = await startRunnerInstall(plugin, options); + return finishRunnerInstall(pending, options.onProgress); +} + +async function startRunnerInstall( + plugin: PluginV4, + options: InstallRunnerOptions, +): Promise { if (!plugin.latest_version) { throw new RunnerMarketplaceError('version-unavailable'); } @@ -252,7 +258,7 @@ export async function installMarketplaceRunner( }; writePendingRunnerInstall(pending); options.onTaskCreated?.(taskId); - return finishRunnerInstall(pending, options.onProgress); + return pending; } function extractPluginLabel(plugin: PluginV4) { @@ -271,6 +277,14 @@ async function finishRunnerInstall( pending: PendingRunnerInstall, onProgress?: (task: AsyncTask) => void, ): Promise { + await waitForRunnerInstall(pending, onProgress); + return waitForRunnerRegistration(pending); +} + +async function waitForRunnerInstall( + pending: PendingRunnerInstall, + onProgress?: (task: AsyncTask) => void, +) { // A refreshed page receives a fresh observation window. The backend task is // authoritative; `startedAt` is display metadata, not a reason to abandon a // still-running installation immediately after recovery. @@ -293,7 +307,11 @@ async function finishRunnerInstall( if (!installCompleted) { throw new RunnerMarketplaceError('install-timeout'); } +} +async function waitForRunnerRegistration( + pending: PendingRunnerInstall, +): Promise { const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; const prefix = runnerPluginPrefix({ author: pending.pluginAuthor, @@ -333,3 +351,45 @@ export async function resumePendingRunnerInstall( if (!pending) return null; return finishRunnerInstall(pending, onProgress); } + +export async function installMarketplaceEventProcessor( + plugin: PluginV4, + options: InstallRunnerOptions, +): Promise { + const pending = await startRunnerInstall(plugin, options); + return finishEventProcessorInstall(pending, options.onProgress); +} + +export async function resumePendingEventProcessorInstall( + scope: string, +): Promise { + const pending = readPendingRunnerInstall(scope); + return pending ? finishEventProcessorInstall(pending) : null; +} + +async function finishEventProcessorInstall( + pending: PendingRunnerInstall, + onProgress?: (task: AsyncTask) => void, +): Promise { + await waitForRunnerInstall(pending, onProgress); + const deadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; + while (Date.now() < deadline) { + const metadata = await httpClient.getAgentMetadata(); + // Only the event registry is authoritative; Agent-only Runners are invalid. + const candidates = (metadata.event_processors ?? []).filter( + (component) => + component.plugin_author === pending.pluginAuthor && + component.plugin_name === pending.pluginName, + ); + const component = + candidates.find((component) => component.id.endsWith('/default')) ?? + candidates[0]; + if (component) { + clearPendingRunnerInstall(pending.scope, pending.taskId); + return component; + } + await wait(1000); + } + clearPendingRunnerInstall(pending.scope, pending.taskId); + throw new RunnerMarketplaceError('registration-timeout'); +} diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 6ed4ca775..de9e9bdf1 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -1,10 +1,10 @@ import EntityLoadState from '@/components/EntityLoadState'; import { useState, useEffect, useRef, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, @@ -22,7 +22,14 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses import { httpClient } from '@/app/infra/http/HttpClient'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; -import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react'; +import { + Settings, + FileText, + Users, + RefreshCw, + Trash2, + Loader2, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { showBotError } from './bot-error'; @@ -56,7 +63,23 @@ export default function BotDetailContent({ id }: { id: string }) { return () => setDetailEntityName(null); }, [id, isCreateMode, bots, setDetailEntityName, t]); - const [activeTab, setActiveTab] = useState('config'); + const [searchParams, setSearchParams] = useSearchParams(); + const requestedTab = searchParams.get('tab'); + const activeTab = + !isCreateMode && + canViewMonitoring && + ['logs', 'sessions'].includes(requestedTab || '') + ? requestedTab! + : 'config'; + const setActiveTab = (tab: string) => + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + next.set('tab', tab); + return next; + }, + { replace: true }, + ); const [adapterLabel, setAdapterLabel] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [basicInfoOpen, setBasicInfoOpen] = useState(false); @@ -72,6 +95,7 @@ export default function BotDetailContent({ id }: { id: string }) { // Enable state managed here so the header switch works const [botEnabled, setBotEnabled] = useState(true); + const [isTogglingEnable, setIsTogglingEnable] = useState(false); const [enableLoaded, setEnableLoaded] = useState(false); // Fetch bot enable state @@ -91,20 +115,27 @@ export default function BotDetailContent({ id }: { id: string }) { const handleEnableToggle = useCallback( async (checked: boolean) => { + if (isTogglingEnable) return; const prev = botEnabled; + setIsTogglingEnable(true); setBotEnabled(checked); try { await httpClient.updateBot(id, { enable: checked }); setBot((current) => current ? { ...current, enable: checked } : current, ); + toast.success( + t(checked ? 'bots.enableConfirmed' : 'bots.disableConfirmed'), + ); refreshBots(); } catch (error) { setBotEnabled(prev); showBotError(error, t('bots.setBotEnableError'), t); + } finally { + setIsTogglingEnable(false); } }, - [id, botEnabled, refreshBots, t], + [id, botEnabled, isTogglingEnable, refreshBots, t], ); function handleFormSubmit() { @@ -210,36 +241,68 @@ export default function BotDetailContent({ id }: { id: string }) {
{enableLoaded && (
+ + {isTogglingEnable ? ( + -
)}
{canManage && (
- + {activeTab === 'config' && ( + + )} - {showLegacyAdapters && ( +
+ + +
+ {(showLegacyAdapters || !!adapterQuery) && (
- {legacyAdapters.map((item) => ( + {galleryLegacyAdapters.map((item) => ( + + + + + + {t('bots.noTargetFound')} + {agents.length > 0 && ( + + {agents.map((a) => ( + + select(encodeTarget('agent', a.uuid || '')) + } + > + + {targetLabel(a)} + {current === encodeTarget('agent', a.uuid || '') && ( + + )} + + ))} + + )} + {pipelines.length > 0 && ( + + {pipelines.map((a) => ( + + select(encodeTarget('pipeline', a.uuid || '')) + } + > + + {targetLabel(a)} + {current === encodeTarget('pipeline', a.uuid || '') && ( + + )} + + ))} + + )} + + +
+ +
+
+ + + event.stopPropagation()} > - {currentLabel()} - - - - - - - - {t('bots.noTargetFound')} - {agents.length > 0 && ( - - {agents.map((a) => ( - select(encodeTarget('agent', a.uuid || ''))} - > - - {targetLabel(a)} - {current === encodeTarget('agent', a.uuid || '') && ( - - )} - - ))} - - )} - {pipelines.length > 0 && ( - - {pipelines.map((a) => ( - - select(encodeTarget('pipeline', a.uuid || '')) - } - > - - {targetLabel(a)} - {current === encodeTarget('pipeline', a.uuid || '') && ( - - )} - - ))} - - )} - - - - + + {t('botSetup.create')} + + {t('botSetup.createDescription')} + + + { + void refreshPipelines(); + try { + const { agent } = await backendClient.getAgent(id); + onAgentCreated(agent); + const compatible = + agent.kind === 'pipeline' + ? pipelineAllowed + : agentSupportsEventPattern(agent, binding.event_pattern); + if (compatible) { + onUpdate({ target_type: agent.kind, target_uuid: id }); + } else { + toast.warning(t('botSetup.incompatible')); + } + setCreating(false); + } catch { + toast.error(t('agents.createError')); + } + }} + /> + + + {selectedTarget && ( + + + + + + {t('bots.pluginSubscriptions.configure')} + + + )} +
); } @@ -1179,9 +1279,10 @@ function RouteDryRunDialog({ interface BindingCardProps { binding: EventBinding; globalIndex: number; - routeStatus?: BotEventRouteStatus; + eventOptions: string[]; agentOptions: Agent[]; + onAgentCreated: (agent: Agent) => void; expandedIds: Set; onToggleExpand: (id: string) => void; onUpdate: (globalIndex: number, patch: Partial) => void; @@ -1193,9 +1294,9 @@ interface BindingCardProps { function BindingCardContent({ binding, globalIndex, - routeStatus, eventOptions, agentOptions, + onAgentCreated, expandedIds, onToggleExpand, onUpdate, @@ -1209,12 +1310,10 @@ function BindingCardContent({ const isExpanded = expandedIds.has(id); const filterCount = (binding.filters as FilterRow[] | undefined)?.length ?? 0; const pipelineAllowed = isMessageEventPattern(binding.event_pattern); - const statusTime = formatRouteStatusTime(routeStatus?.timestamp); - const statusDetail = routeStatusDetail(routeStatus, t); return (
)} + {!isEnabled &&
); } diff --git a/web/src/app/home/bots/components/bot-form/EventRoutingHelp.css b/web/src/app/home/bots/components/bot-form/EventRoutingHelp.css new file mode 100644 index 000000000..cfd3b20b8 --- /dev/null +++ b/web/src/app/home/bots/components/bot-form/EventRoutingHelp.css @@ -0,0 +1,23 @@ +.routing-help-dot { + animation: route-travel 4.8s ease-in-out infinite; + animation-delay: var(--route-delay); + opacity: 0; +} +.routing-help-target { + animation: route-receive 4.8s ease-in-out infinite; + animation-delay: var(--route-delay); +} +.routing-help-paused * { animation-play-state: paused; } +@keyframes route-travel { + 0%, 4% { opacity: 0; transform: translateX(0); } + 8% { opacity: 1; } + 25% { opacity: 1; transform: translateX(40px); } + 30%, 100% { opacity: 0; transform: translateX(40px); } +} +@keyframes route-receive { + 0%, 24%, 45%, 100% { transform: translateY(0); } + 30%, 36% { transform: translateY(-2px); } +} +@media (prefers-reduced-motion: reduce) { + .routing-help-dot, .routing-help-target { animation: none; } +} diff --git a/web/src/app/home/bots/components/bot-form/EventRoutingHelp.tsx b/web/src/app/home/bots/components/bot-form/EventRoutingHelp.tsx new file mode 100644 index 000000000..07c4430f0 --- /dev/null +++ b/web/src/app/home/bots/components/bot-form/EventRoutingHelp.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import './EventRoutingHelp.css'; + +const routeColors = [ + 'text-sky-700 dark:text-sky-300 [&_.route-node]:border-sky-200 [&_.route-node]:bg-sky-50 dark:[&_.route-node]:border-sky-800 dark:[&_.route-node]:bg-sky-950/40', + 'text-amber-700 dark:text-amber-300 [&_.route-node]:border-amber-200 [&_.route-node]:bg-amber-50 dark:[&_.route-node]:border-amber-800 dark:[&_.route-node]:bg-amber-950/40', + 'text-emerald-700 dark:text-emerald-300 [&_.route-node]:border-emerald-200 [&_.route-node]:bg-emerald-50 dark:[&_.route-node]:border-emerald-800 dark:[&_.route-node]:bg-emerald-950/40', +]; + +export default function EventRoutingHelp() { + const { t } = useTranslation(); + const [paused, setPaused] = useState(false); + const text = (key: string) => t(`bots.routingHelp.${key}`); + return ( + + + + + +

{text('title')}

+

+ {text('intro')} +

+
+
+ {text('events')} + {text('agents')} +
+
+ {['message', 'friend', 'group'].map((kind, index) => ( +
+
+ {text(kind)} +
+ +
+ {text(`${kind}Action`)} +
+
+ ))} +
+
+ {text('rules')} + +
+
+

{text('instructions')}

+
+

{text('plugin')}

+ + {text('docs')} ↗ + +
+
+
+ ); +} diff --git a/web/src/app/home/bots/components/bot-form/LegacyAdapterHelp.tsx b/web/src/app/home/bots/components/bot-form/LegacyAdapterHelp.tsx new file mode 100644 index 000000000..360b19a3d --- /dev/null +++ b/web/src/app/home/bots/components/bot-form/LegacyAdapterHelp.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +export default function LegacyAdapterHelp() { + const { t } = useTranslation(); + return ( + + + + + +

{t('bots.legacyAdapters')}

+

+ {t('bots.legacyAdaptersHint')} +

+
+
+ ); +} 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 582cfb720..bdce4047f 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -1,3 +1,14 @@ +import { useCurrentWorkspace } from '@/app/infra/http'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import LoadErrorState from '@/components/LoadErrorState'; import React, { useState, useEffect, @@ -12,6 +23,7 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { + RotateCcw, Ban, Bot, Copy, @@ -142,6 +154,23 @@ const BotSessionMonitor = forwardRef< BotSessionMonitorProps >(function BotSessionMonitor({ botId }, ref) { const { t } = useTranslation(); + const workspace = useCurrentWorkspace(); + const canReset = workspace?.permissions.includes('resource.manage') ?? false; + const [resetTarget, setResetTarget] = useState(null); + const [resetting, setResetting] = useState(false); + const resetContext = async () => { + if (!resetTarget || resetting) return; + setResetting(true); + try { + await httpClient.resetSessionContext(botId, resetTarget); + toast.success(t('bots.sessionMonitor.resetSuccess')); + setResetTarget(null); + } catch { + toast.error(t('bots.sessionMonitor.resetError')); + } finally { + setResetting(false); + } + }; const [sessions, setSessions] = useState([]); const [sessionTotal, setSessionTotal] = useState(0); const [sessionPage, setSessionPage] = useState(0); @@ -319,7 +348,7 @@ const BotSessionMonitor = forwardRef< // Collect user message IDs for feedback matching const userMsgIds = new Set( - sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id), + sorted.filter(isUserMessage).map((m) => m.id), ); if (userMsgIds.size > 0) { @@ -389,9 +418,10 @@ const BotSessionMonitor = forwardRef< }, [selectedSessionId, messagePage, loadMessages]); useEffect(() => { + if (loadingMessages || messageError) return; if (messages.length === 0 && toolCalls.length === 0) return; - // Wait for DOM to render the new messages before scrolling - requestAnimationFrame(() => { + // The timeline is hidden until all message data has finished loading. + const frame = requestAnimationFrame(() => { const container = messagesContainerRef.current; if (container) { const viewport = container.querySelector( @@ -401,7 +431,8 @@ const BotSessionMonitor = forwardRef< scrollTarget.scrollTop = scrollTarget.scrollHeight; } }); - }, [messages, toolCalls]); + return () => cancelAnimationFrame(frame); + }, [loadingMessages, messageError, messages, toolCalls]); const parseMessageChain = (content: string): MessageChainComponent[] => { try { @@ -409,17 +440,44 @@ const BotSessionMonitor = forwardRef< if (Array.isArray(parsed)) { return parsed as MessageChainComponent[]; } + if (parsed && typeof parsed === 'object') { + const chain = parsed.message_chain ?? parsed.event?.message_chain; + if (Array.isArray(chain)) return chain as MessageChainComponent[]; + if (Array.isArray(chain?.root)) + return chain.root as MessageChainComponent[]; + if (Array.isArray(parsed.contents)) { + const components: MessageChainComponent[] = []; + for (const part of parsed.contents) { + if (part.type === 'text' && typeof part.text === 'string') { + components.push({ type: 'Plain', text: part.text } as Plain); + } else if (part.type === 'image' && part.image_url) { + components.push({ + type: 'Image', + url: part.image_url, + } as MessageChainComponent); + } + } + if (components.length) return components; + } + if (typeof parsed.text === 'string') { + return [{ type: 'Plain', text: parsed.text } as Plain]; + } + } } catch { // Not JSON, return as plain text } return [{ type: 'Plain', text: content } as Plain]; }; - const isUserMessage = (msg: SessionMessage): boolean => { + function isUserMessage(msg: SessionMessage): boolean { + // Older processing-error records inherited the default user role. + if (msg.status === 'error' && msg.message_content.startsWith('Error: ')) { + return false; + } if (msg.role === 'assistant') return false; if (msg.role === 'user') return true; return !msg.runner_name; - }; + } const renderMessageComponent = ( component: MessageChainComponent, @@ -664,7 +722,7 @@ const BotSessionMonitor = forwardRef< {/* Left Panel: Session List */}
{/* Admin header */} -
+
-
+ ) : sessions.length === 0 ? (
{t('bots.sessionMonitor.noSessions')} @@ -850,7 +900,7 @@ const BotSessionMonitor = forwardRef< selectedSession?.user_id || selectedSessionId.slice(0, 20)}
-
+
{parseSessionType(selectedSessionId) && ( {parseSessionType(selectedSessionId)} )} @@ -913,6 +963,18 @@ const BotSessionMonitor = forwardRef< )}
+ {canReset && ( + + )}
{/* Messages Area */} @@ -922,45 +984,25 @@ const BotSessionMonitor = forwardRef< >
{analysisError && !loadingMessages && ( -
-

- {t('monitoring.toolCalls.title')}:{' '} - {t('monitoring.loadError')} -

- -
+ + loadMessages(selectedSessionId, messagePage) + } + /> )} {loadingMessages ? (
{t('bots.sessionMonitor.loading')}
) : messageError ? ( -
-

{t('monitoring.loadError')}

- -
+ + loadMessages(selectedSessionId, messagePage) + } + /> ) : timelineItems.length === 0 ? (
{t('bots.sessionMonitor.noMessages')} @@ -1181,38 +1223,73 @@ const BotSessionMonitor = forwardRef< )}
-
- - - {messagePage + 1} / {messagePageCount} · {messageTotal} - - -
+ {messagePageCount > 1 && ( +
+ + + {messagePage + 1} / {messagePageCount} · {messageTotal} + + +
+ )} )}
+ { + if (!open && !resetting) setResetTarget(null); + }} + > + + + {t('bots.sessionMonitor.resetContext')} + + {t('bots.sessionMonitor.resetDescription')} + + + + + + + + ) : {}; + const running = data.status === 'running'; const failed = !!data.error; const denied = data.status === 'denied'; const partial = !!data.truncated; - const Icon = - failed || partial ? CircleAlert : denied ? MinusCircle : CheckCircle2; + const Icon = running + ? LoaderCircle + : failed || partial + ? CircleAlert + : denied + ? MinusCircle + : CheckCircle2; const items = Array.isArray(result) ? result : Array.isArray(data.items) @@ -56,13 +63,15 @@ export default function AssistantToolResult({ : t(`assistant.operations.${tool?.name}`, { defaultValue: t('assistant.toolResult'), }); - const status = failed - ? 'failed' - : denied - ? 'denied' - : partial - ? 'partial' - : 'completed'; + const status = running + ? 'toolRunning' + : failed + ? 'failed' + : denied + ? 'denied' + : partial + ? 'partial' + : 'completed'; const url = typeof data.url === 'string' && /^\/home\/(pipelines|knowledge)\?id=[\w-]+$/.test(data.url) @@ -79,7 +88,7 @@ export default function AssistantToolResult({ return ( ); } return ( -
-
+
+
- {label} - + {label} + {tool && t(`assistant.${status}`)}
- {failed ? ( + {running ? ( +

{t('assistant.toolRunning')}

+ ) : failed ? (

{t('assistant.operationFailed')}

) : denied ? (

@@ -136,7 +147,7 @@ export default function AssistantToolResult({ )} {name &&

{name}

} {items && ( -
    +
      {items.slice(0, 6).map((item: unknown, index: number) => { const entry = item && typeof item === 'object' @@ -168,9 +179,9 @@ export default function AssistantToolResult({ )} )} -
      +
      {t('assistant.details')} -
      +        
                 {tool ? JSON.stringify(tool.result, null, 2) : content}
               
      diff --git a/web/src/app/home/components/WorkspaceAssistant.tsx b/web/src/app/home/components/WorkspaceAssistant.tsx index 58834a7b0..8457e6992 100644 --- a/web/src/app/home/components/WorkspaceAssistant.tsx +++ b/web/src/app/home/components/WorkspaceAssistant.tsx @@ -1,43 +1,43 @@ +import { useEffect, useRef, useState, useId, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Plus, Send, ChevronDown, LoaderCircle, Square } from 'lucide-react'; import { - useEffect, - useRef, - useState, - type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, -} from 'react'; -import { MessageCircle, Plus, Send, X, LoaderCircle } from 'lucide-react'; + RiSparkling2Line, + RiArrowUpSLine, + RiCompass3Line, + RiMagicLine, + RiArrowRightUpLine, +} from '@remixicon/react'; +import styles from './workspace-assistant.module.css'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { backendClient, useCurrentWorkspace, userInfo } from '@/app/infra/http'; +import rehypeHighlight from 'rehype-highlight'; +import '@/styles/github-markdown.css'; +import { + backendClient, + systemInfo, + useCurrentWorkspace, + userInfo, +} from '@/app/infra/http'; +import { toast } from 'sonner'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { MODEL_CONFIGURATION_CHANGED } from '@/app/infra/http/modelConfigEvents'; +import SettingsDialog, { + SettingsSection, +} from './settings-dialog/SettingsDialog'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import DynamicFormItemComponent from './dynamic-form/DynamicFormItemComponent'; import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic'; import AssistantToolResult, { AssistantTool } from './AssistantToolResult'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { - ASSISTANT_RAIL_WIDTH, - clampAssistantPosition as clampInViewport, - resolveAssistantEdge, - shouldCollapseRail, - shouldExpandRail, - type AssistantDragPosition, - type AssistantEdge, -} from './assistant-dock'; - -const ASSISTANT_LONG_PRESS_MS = 260; - -function viewport(): { width: number; height: number } { - return { width: window.innerWidth, height: window.innerHeight }; -} - -function clampAssistantPosition(x: number, y: number): AssistantDragPosition { - return clampInViewport(x, y, viewport()); -} +import { LANGBOT_MODELS_PROVIDER_REQUESTER } from './models-dialog/types'; type Conversation = { uuid: string; @@ -48,6 +48,12 @@ type Conversation = { error: string | null; model_name: string | null; model_uuid: string | null; + progress?: { + text?: string; + phase?: 'thinking' | 'tool'; + round?: number; + tool?: Omit; + }; }; export default function WorkspaceAssistant() { @@ -59,348 +65,437 @@ export default function WorkspaceAssistant() { return null; const identity = `${workspace.workspace.uuid}:${userInfo.account_uuid}`; return ( - ); } -function AssistantPanel({ storageKey }: { storageKey: string }) { +function AssistantEntry({ storageKey }: { storageKey: string }) { const { t } = useTranslation(); - const [open, setOpen] = useState(false); + const [available, setAvailable] = useState(null); + const [localModel, setLocalModel] = useState< + { uuid: string; name: string } | undefined + >(); + const [failed, setFailed] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [section, setSection] = useState('models'); + const [loginBusy, setLoginBusy] = useState(false); + const [retry, setRetry] = useState(0); + + useEffect(() => { + let active = true; + let latestCheck = 0; + async function check() { + const currentCheck = ++latestCheck; + try { + const { models } = await backendClient.getProviderLLMModels(); + if (active && currentCheck === latestCheck) { + const usableModels = models.filter( + (model) => + !!model.uuid?.trim() && + model.abilities?.includes('func_call') && + (model.provider?.requester !== + LANGBOT_MODELS_PROVIDER_REQUESTER || + (userInfo?.account_type === 'space' && + !systemInfo.disable_models_service)), + ); + setAvailable(usableModels.length > 0); + const firstLocalModel = + userInfo?.account_type !== 'space' ? usableModels[0] : undefined; + setLocalModel( + firstLocalModel + ? { uuid: firstLocalModel.uuid, name: firstLocalModel.name } + : undefined, + ); + setFailed(false); + } + } catch { + if (active && currentCheck === latestCheck) setFailed(true); + } + } + if (!settingsOpen) void check(); + window.addEventListener('focus', check); + window.addEventListener(MODEL_CONFIGURATION_CHANGED, check); + return () => { + active = false; + window.removeEventListener('focus', check); + window.removeEventListener(MODEL_CONFIGURATION_CHANGED, check); + }; + }, [settingsOpen, retry]); + + async function login() { + setLoginBusy(true); + try { + const response = await httpClient.getSpaceBindAuthorizeUrl( + `${window.location.origin}/auth/space/callback?mode=bind`, + ); + window.location.href = response.authorize_url; + } catch { + toast.error(t('common.spaceLoginFailed')); + setLoginBusy(false); + } + } + + return ( + <> + +

      + {t( + failed + ? 'assistant.providerCheckFailed' + : available === null + ? 'assistant.preparing' + : 'assistant.setupRequired', + )} +

      +
      + {failed ? ( + + ) : available === false ? ( + <> + + + + ) : null} +
      +
+ } + /> + + + ); +} + +type AssistantPanelProps = { + storageKey: string; + canUseModels: boolean; + initialModel?: { uuid: string; name: string }; + setup: ReactNode; +}; + +function AssistantSessions(props: AssistantPanelProps) { + const { storageKey } = props; + const [version, setVersion] = useState(0); + function select(id: string | null) { + if (id) localStorage.setItem(storageKey, id); + else localStorage.removeItem(storageKey); + setVersion((value) => value + 1); + } + return ; +} + +const ASSISTANT_HEIGHT_STORAGE_KEY = 'langbot-assistant-panel-height'; +const ASSISTANT_POSITION_STORAGE_KEY = 'langbot-assistant-panel-position'; +const ASSISTANT_OPEN_STORAGE_KEY = 'langbot-assistant-panel-open'; + +function AssistantPanel({ + storageKey, + canUseModels, + initialModel, + setup, + onSelect, +}: AssistantPanelProps & { + onSelect: (id: string | null) => void; +}) { + const { t } = useTranslation(); + const initialModelUuid = initialModel?.uuid; + const initialModelName = initialModel?.name; + const [open, setOpen] = useState(() => { + try { + return localStorage.getItem(ASSISTANT_OPEN_STORAGE_KEY) !== 'false'; + } catch { + return true; + } + }); + useEffect(() => { + try { + localStorage.setItem(ASSISTANT_OPEN_STORAGE_KEY, String(open)); + } catch { + // The panel remains usable when browser storage is unavailable. + } + }, [open]); + const [closing, setClosing] = useState(false); + const [horizontalPosition, setHorizontalPosition] = useState(() => { + try { + const saved = Number( + localStorage.getItem(ASSISTANT_POSITION_STORAGE_KEY), + ); + return Number.isFinite(saved) ? Math.max(-1, Math.min(1, saved)) : 0; + } catch { + return 0; + } + }); + const [viewportWidth, setViewportWidth] = useState(window.innerWidth); + useEffect(() => { + const updateWidth = () => setViewportWidth(window.innerWidth); + window.addEventListener('resize', updateWidth); + return () => window.removeEventListener('resize', updateWidth); + }, []); + useEffect(() => { + try { + localStorage.setItem( + ASSISTANT_POSITION_STORAGE_KEY, + String(horizontalPosition), + ); + } catch { + // Positioning remains available when storage is blocked. + } + }, [horizontalPosition]); + // Store a relative position so it follows screen changes while keeping a + // 12px margin. The collapsed dock remains aligned with the expanded panel. + const horizontalRange = Math.max(0, (viewportWidth - 480 - 24) / 2); + const moveStart = useRef<{ x: number; offset: number } | null>(null); + const dockWasDragged = useRef(false); + const [panelHeight, setPanelHeight] = useState(() => { + try { + const saved = Number(localStorage.getItem(ASSISTANT_HEIGHT_STORAGE_KEY)); + return Number.isFinite(saved) && saved > 0 + ? Math.max(280, saved) + : undefined; + } catch { + return undefined; + } + }); + useEffect(() => { + if (panelHeight === undefined) return; + try { + localStorage.setItem(ASSISTANT_HEIGHT_STORAGE_KEY, String(panelHeight)); + } catch { + // Resizing still works when browser storage is unavailable. + } + }, [panelHeight]); + const panelRef = useRef(null); + const resizeStart = useRef<{ y: number; height: number } | null>(null); + const resizeHeight = (height: number) => { + const maximum = Math.max(160, window.innerHeight - 48); + setPanelHeight(Math.min(maximum, Math.max(Math.min(280, maximum), height))); + }; + const closeTimer = useRef(null); const [conversation, setConversation] = useState(null); const [text, setText] = useState(''); const [sending, setBusy] = useState(false); const [loading, setLoading] = useState(false); - const busy = sending || loading; + const busy = sending || loading || conversation?.status === 'running'; + const [stopping, setStopping] = useState(false); + const [sessions, setSessions] = useState< + { uuid: string; title: string; status: Conversation['status'] }[] + >([]); const [error, setError] = useState(false); const [modelUuid, setModelUuid] = useState(''); + const [modelName, setModelName] = useState(); + const manualModel = useRef(false); + const [modelError, setModelError] = useState(false); + const [liveText, setLiveText] = useState(''); + const [liveTool, setLiveTool] = useState(null); + const [phase, setPhase] = useState<'thinking' | 'tool' | null>(null); + const [round, setRound] = useState(1); const [pendingText, setPendingText] = useState(null); const controller = useRef(new AbortController()); - const end = useRef(null); + const messageList = useRef(null); + const followLatest = useRef(true); + const scrollPosition = useRef(0); - const [dragPosition, setDragPosition] = - useState(null); - const [dragging, setDragging] = useState(false); - const [dockedEdge, setDockedEdge] = useState(null); - // The rail collapses only while the pointer is away. Hovering any part of the - // control restores the full button, and the two states never race because - // every transition is derived from the same `dockedEdge` snapshot. - const [railExpanded, setRailExpanded] = useState(false); - const pointerOver = useRef(false); - const containerRef = useRef(null); - const hoverLocked = useRef(false); - const dragState = useRef<{ - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - active: boolean; - } | null>(null); - const longPressTimer = useRef(null); - const suppressClick = useRef(false); - const buttonRef = useRef(null); + const panelId = useId(); + const expandButton = useRef(null); + const input = useRef(null); - function clearLongPressTimer() { - if (longPressTimer.current !== null) { - window.clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - } + useEffect( + () => () => { + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + }, + [], + ); - useEffect(() => clearLongPressTimer, []); - - /* - * Seed the resting position from the rendered default (bottom-right) so the - * very first visit already docks and collapses. Without this the button would - * only ever dock after a manual drag, which reads as "collapse is broken". - */ - useEffect(() => { - if (window.localStorage.getItem(`${storageKey}:button-position`)) return; - const rect = buttonRef.current?.getBoundingClientRect(); - if (!rect) return; - const position = clampAssistantPosition(rect.left, rect.top); - setDragPosition(position); - const edge = resolveAssistantEdge(position.x, viewport()); - setDockedEdge(edge); - if (edge) { - try { - window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge); - } catch { - // Persisting the dock is best-effort only. - } - } - }, [storageKey]); - - useEffect(() => { - const stored = window.localStorage.getItem(`${storageKey}:button-position`); - if (stored) { - try { - const parsed = JSON.parse(stored) as AssistantDragPosition; - if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') { - const position = clampAssistantPosition(parsed.x, parsed.y); - setDragPosition(position); - setDockedEdge(resolveAssistantEdge(position.x, viewport())); - } - } catch { - window.localStorage.removeItem(`${storageKey}:button-position`); - } - } - const storedEdge = window.localStorage.getItem( - `${storageKey}:button-docked-edge`, - ); - if (storedEdge === 'left' || storedEdge === 'right') { - setDockedEdge((current) => current ?? storedEdge); - } - }, [storageKey]); - - useEffect(() => { - const onResize = () => { - setDragPosition((prev) => { - if (!prev) return prev; - const next = clampAssistantPosition(prev.x, prev.y); - setDockedEdge(resolveAssistantEdge(next.x, viewport())); - return next; - }); + function collapse() { + if (closeTimer.current !== null) return; + const finish = () => { + closeTimer.current = null; + setOpen(false); + setClosing(false); + window.requestAnimationFrame(() => expandButton.current?.focus()); }; - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - - function persistDragPosition(position: AssistantDragPosition) { - try { - window.localStorage.setItem( - `${storageKey}:button-position`, - JSON.stringify(position), - ); - } catch { - // Ignore storage failures (private mode or quota); dragging still works. + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + finish(); + return; } + setClosing(true); + closeTimer.current = window.setTimeout(finish, 180); } - function applyRestingPosition(position: AssistantDragPosition) { - const edge = resolveAssistantEdge(position.x, viewport()); - setDragPosition(position); - setDockedEdge(edge); - // A fresh dock always collapses; the rail re-expands on the next hover. - setRailExpanded(false); - // Hold the collapse until the pointer leaves, otherwise the still-hovering - // cursor would fight the new state. - if (edge) lockHoverUntilPointerExit(); - try { - if (edge) { - window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge); - } else { - window.localStorage.removeItem(`${storageKey}:button-docked-edge`); - } - } catch { - // Persisting the dock is best-effort only. - } + function expand() { + setOpen(true); + window.requestAnimationFrame(() => input.current?.focus()); } - function endDrag(commit: boolean, clientX?: number, clientY?: number) { - const state = dragState.current; - clearLongPressTimer(); - dragState.current = null; - if (!state?.active) return; - setDragging(false); - const next = clampAssistantPosition( - state.originX + ((clientX ?? state.startX) - state.startX), - state.originY + ((clientY ?? state.startY) - state.startY), - ); - if (commit) { - applyRestingPosition(next); - persistDragPosition(next); - } else { - setDragPosition(next); - } - } - - /* - * Rail hover recovery. The subtle race: a drag usually ends with the pointer - * still sitting on the button, so the browser fires no new `pointerenter` - * once the button collapses. Re-expanding on `pointermove` would therefore - * undo the collapse immediately. - * - * Instead the drop "locks" hover until the pointer physically leaves the - * control. A window-level move listener watches for that exit (the element - * can shift under a stationary cursor, so `pointerleave` alone is not - * reliable) and clears the lock; only then does hovering reveal the button. - */ - function handlePointerEnter() { - pointerOver.current = true; - if (hoverLocked.current) return; - if (shouldExpandRail({ dockedEdge, railExpanded, dragging })) - setRailExpanded(true); - } - - function handlePointerLeave() { - pointerOver.current = false; - hoverLocked.current = false; - // Never collapse mid-drag; the drop handler owns the final state. - if (dragState.current || dragging) return; - if (shouldCollapseRail({ dockedEdge, railExpanded, dragging })) - setRailExpanded(false); - } - - /* - * A click or an aborted swipe never arms the long press, so no drop handler - * runs. Re-sync the rail from the real pointer position on release, otherwise - * a button expanded by hover would stay expanded with the cursor gone. - */ - function settleRailAfterRelease() { - if (pointerOver.current || dragState.current || dragging) return; - if (shouldCollapseRail({ dockedEdge, railExpanded, dragging })) - setRailExpanded(false); - } - - function lockHoverUntilPointerExit() { - hoverLocked.current = true; - const releaseOnExit = (moveEvent: PointerEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const outside = - moveEvent.clientX < rect.left || - moveEvent.clientX > rect.right || - moveEvent.clientY < rect.top || - moveEvent.clientY > rect.bottom; - if (!outside) return; - hoverLocked.current = false; - window.removeEventListener('pointermove', releaseOnExit); - }; - window.addEventListener('pointermove', releaseOnExit); - } - - function onButtonPointerDown(event: ReactPointerEvent) { - if (event.button !== 0) return; - suppressClick.current = false; - const rect = buttonRef.current?.getBoundingClientRect(); - if (!rect) return; - const state = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: rect.left, - originY: rect.top, - active: false, - }; - dragState.current = state; - clearLongPressTimer(); - - const onWindowMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== state.pointerId) return; - const deltaX = moveEvent.clientX - state.startX; - const deltaY = moveEvent.clientY - state.startY; - - if (!state.active) { - // Cancel the long-press when the user is clearly scrolling or swiping. - if (Math.hypot(deltaX, deltaY) > 8) clearLongPressTimer(); - return; - } - - moveEvent.preventDefault(); - setDragPosition( - clampAssistantPosition(state.originX + deltaX, state.originY + deltaY), - ); - }; - - const onWindowUp = (upEvent: PointerEvent) => { - if (upEvent.pointerId !== state.pointerId) return; - window.removeEventListener('pointermove', onWindowMove); - window.removeEventListener('pointerup', onWindowUp); - window.removeEventListener('pointercancel', onWindowUp); - endDrag(true, upEvent.clientX, upEvent.clientY); - }; - - window.addEventListener('pointermove', onWindowMove, { passive: false }); - window.addEventListener('pointerup', onWindowUp); - window.addEventListener('pointercancel', onWindowUp); - - // Release outside a drag still needs to reconcile the rail. - window.addEventListener('pointerup', settleRailAfterRelease, { - once: true, - }); - - longPressTimer.current = window.setTimeout(() => { - if (dragState.current !== state) return; - state.active = true; - setDragging(true); - suppressClick.current = true; - setDragPosition(clampAssistantPosition(state.originX, state.originY)); - }, ASSISTANT_LONG_PRESS_MS); - } - - function onButtonClick(event: ReactMouseEvent) { - // After a drag the trailing click must not toggle the panel. Radix's - // trigger skips its own toggle when the event default is prevented. - if (suppressClick.current) { - suppressClick.current = false; - event.preventDefault(); - event.stopPropagation(); - } - } - - useEffect(() => { - if (conversation?.model_uuid) setModelUuid(conversation.model_uuid); - }, [conversation?.model_uuid]); - useEffect(() => { const abort = new AbortController(); controller.current = abort; + setLoading(true); + async function initialize() { + try { + const id = localStorage.getItem(storageKey); + let saved: Conversation | null = null; + if (id) { + saved = await backendClient.request({ + method: 'GET', + url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`, + signal: abort.signal, + }); + if (abort.signal.aborted) return; + setConversation(saved); + } + if (!canUseModels) return; + const model = saved?.model_uuid + ? { uuid: saved.model_uuid, name: saved.model_name ?? undefined } + : initialModelUuid + ? { uuid: initialModelUuid, name: initialModelName } + : await backendClient.request<{ uuid: string; name: string }>({ + method: 'GET', + url: '/api/v1/assistant/recommended-model', + signal: abort.signal, + }); + if (!abort.signal.aborted && !manualModel.current) { + setModelUuid(model.uuid); + setModelName(model.name); + } + } catch { + if (!abort.signal.aborted) setModelError(true); + } finally { + if (!abort.signal.aborted) setLoading(false); + } + } + void initialize(); return () => abort.abort(); - }, []); + }, [storageKey, canUseModels, initialModelUuid, initialModelName]); useEffect(() => { - if (!open || busy || (conversation && conversation.status !== 'running')) - return; - const id = localStorage.getItem(storageKey); - if (!id) return; let active = true; - setLoading(true); - backendClient - .request({ - method: 'GET', - url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`, - signal: controller.current.signal, - }) - .then((value) => { - if (active) setConversation(value); - }) - .catch(() => { - if (active) { - localStorage.removeItem(storageKey); - setError(true); + let timer: ReturnType; + async function refresh() { + try { + const result = await backendClient.request<{ + conversations: typeof sessions; + }>({ + method: 'GET', + url: '/api/v1/assistant/conversations', + signal: controller.current.signal, + }); + if (active) setSessions(result.conversations ?? []); + if (!sending && conversation?.uuid) { + const latest = await backendClient.request({ + method: 'GET', + url: `/api/v1/assistant/conversations/${conversation.uuid}`, + signal: controller.current.signal, + }); + if (active) { + setConversation(latest); + setLiveText(latest.progress?.text ?? ''); + setPhase(latest.progress?.phase ?? null); + setRound(latest.progress?.round ?? 1); + setLiveTool( + latest.progress?.tool + ? { ...latest.progress.tool, result: { status: 'running' } } + : null, + ); + } } - }) - .finally(() => { - if (active) setLoading(false); - }); + } catch { + // Polling is read-only. A failed refresh never retries a turn. + } finally { + if (active) timer = setTimeout(refresh, 1500); + } + } + void refresh(); return () => { active = false; - setLoading(false); + clearTimeout(timer); }; - // Load only when opening; turn requests own subsequent state updates. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, storageKey]); + }, [conversation?.uuid, sending]); + + async function stop() { + if (!conversation || stopping) return; + setStopping(true); + try { + const latest = await backendClient.request({ + method: 'POST', + url: `/api/v1/assistant/conversations/${conversation.uuid}/stop`, + data: { revision: conversation.revision }, + signal: controller.current.signal, + }); + setConversation(latest); + setLiveText(''); + setLiveTool(null); + setPhase(null); + } catch { + setError(true); + } finally { + setStopping(false); + } + } + + // Restore the reading position when reopening; new conversations start at the bottom. + useEffect(() => { + if (open && messageList.current && !followLatest.current) { + messageList.current.scrollTop = scrollPosition.current; + } + }, [open]); - // `open` is a dependency so a freshly opened panel jumps to the newest turn - // instead of leaving the user at the oldest message. The rAF waits for the - // popover to lay out before measuring the sentinel. useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { - end.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + const list = messageList.current; + if (list && followLatest.current) { + list.scrollTop = list.scrollHeight; + scrollPosition.current = list.scrollTop; + } }); return () => window.cancelAnimationFrame(frame); - }, [open, conversation, busy, pendingText]); + }, [open, conversation, busy, pendingText, liveText, liveTool, panelHeight]); async function submit(approved?: boolean) { if ( + !canUseModels || busy || (approved === undefined && - (!text.trim() || (conversation && conversation.status !== 'ready'))) + (!text.trim() || + !modelUuid || + (conversation && conversation.status !== 'ready'))) ) return; const sentText = approved === undefined ? text.trim() : null; @@ -411,6 +506,8 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { } setBusy(true); setError(false); + setRound(1); + setPhase('thinking'); try { let current = conversation; if (!current) { @@ -422,24 +519,48 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { localStorage.setItem(storageKey, current.uuid); setConversation(current); } - const updated = await backendClient.request({ - method: 'POST', - url: `/api/v1/assistant/conversations/${current.uuid}/turn`, - data: { + const updated = await backendClient.streamAssistant( + current.uuid, + { revision: current.revision, ...(approved === undefined - ? { - text: sentText, - ...(modelUuid ? { model_uuid: modelUuid } : {}), - } + ? { text: sentText!, model_uuid: modelUuid } : { approved }), }, - timeout: 130000, - signal: controller.current.signal, - }); + (event) => { + if (event.kind === 'snapshot') { + const snapshot = event.data as Conversation; + setConversation(snapshot); + setPendingText(null); + setLiveText(''); + setLiveTool(null); + if (snapshot.model_uuid) { + setModelUuid(snapshot.model_uuid); + setModelName(snapshot.model_name ?? undefined); + } + } else if (event.kind === 'text') { + setLiveText((event.data as { text: string }).text); + } else if (event.kind === 'phase') { + const state = event.data as { + phase: 'thinking' | 'tool'; + round?: number; + tool?: Omit; + }; + setPhase(state.phase); + if (state.round) setRound(state.round); + setLiveTool( + state.tool + ? { ...state.tool, result: { status: 'running' } } + : null, + ); + } + }, + controller.current.signal, + ); setConversation(updated); if (sentText) setPendingText(null); } catch { + if (controller.current.signal.aborted) return; setError(true); // A lost response may already have executed a write. Refresh, never replay. const id = localStorage.getItem(storageKey); @@ -466,291 +587,633 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { } } finally { setBusy(false); + setLiveText(''); + setLiveTool(null); + setPhase(null); } } function reset() { - localStorage.removeItem(storageKey); - setConversation(null); - setText(''); - setError(false); - setPendingText(null); + onSelect(null); } - // Collapse only when docked, idle and not hovered. The container keeps its - // resting box, so the hidden button and the visible strip share one anchor - // and cannot drift apart; only the strip is painted while collapsed. - const railCollapsed = !!dockedEdge && !railExpanded && !dragging; - const inlinePosition = dragPosition - ? { left: dragPosition.x, top: dragPosition.y } - : undefined; + // A user message starts a turn; its tool steps and replies share one bubble. + const messageGroups: { + key: number; + role: 'user' | 'assistant'; + messages: Conversation['messages']; + }[] = []; + const visibleMessages: Conversation['messages'] = [ + ...(conversation?.messages ?? []), + ]; + if (liveText) visibleMessages.push({ role: 'assistant', content: liveText }); + if (liveTool) + visibleMessages.push({ role: 'tool', content: '', tool: liveTool }); + for (const [index, message] of visibleMessages.entries()) { + const role = message.role === 'user' ? 'user' : 'assistant'; + const previous = messageGroups.at(-1); + if (role === 'assistant' && previous?.role === 'assistant') { + previous.messages.push(message); + } else { + messageGroups.push({ key: index, role, messages: [message] }); + } + } - return ( -
- - + return createPortal( +
+
+ {!open && ( - - {railCollapsed && ( -