diff --git a/.github/workflows/context7-refresh.yml b/.github/workflows/context7-refresh.yml
new file mode 100644
index 000000000..4f380df6c
--- /dev/null
+++ b/.github/workflows/context7-refresh.yml
@@ -0,0 +1,54 @@
+name: Refresh Context7 docs
+
+# Re-index the public documentation in Context7 whenever the English docs change.
+# Requires a repository secret CONTEXT7_API_KEY (context7.com/dashboard) and a
+# Context7 library registered for this repository; both are one-time owner steps.
+# Until the secret exists every run is a no-op instead of a failure.
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - "docs/en/**"
+ - "context7.json"
+ - ".github/workflows/context7-refresh.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: context7-refresh
+ cancel-in-progress: true
+
+jobs:
+ refresh:
+ name: Refresh Context7 library
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ env:
+ CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}
+ CONTEXT7_LIBRARY: /langbot-app/langbot
+ steps:
+ - name: Skip when the Context7 API key is not configured
+ if: env.CONTEXT7_API_KEY == ''
+ run: |
+ echo "CONTEXT7_API_KEY is not configured; skipping the Context7 refresh."
+ echo "Add the secret and register the library, then re-run this workflow."
+
+ - name: Trigger Context7 refresh
+ if: env.CONTEXT7_API_KEY != ''
+ run: |
+ set -euo pipefail
+ body=$(printf '{"libraryName":"%s"}' "$CONTEXT7_LIBRARY")
+ code=$(curl -sS -o "$RUNNER_TEMP/refresh.json" -w '%{http_code}' \
+ -X POST https://context7.com/api/v1/refresh \
+ -H 'Content-Type: application/json' \
+ -H "Authorization: Bearer ${CONTEXT7_API_KEY}" \
+ -d "$body")
+ cat "$RUNNER_TEMP/refresh.json"
+ if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
+ echo "Context7 refresh failed with HTTP $code" >&2
+ exit 1
+ fi
+ echo "Requested a refresh of ${CONTEXT7_LIBRARY}."
diff --git a/.github/workflows/sync-blog-articles.yml b/.github/workflows/sync-blog-articles.yml
index 03a9d3ca1..133687309 100644
--- a/.github/workflows/sync-blog-articles.yml
+++ b/.github/workflows/sync-blog-articles.yml
@@ -14,7 +14,7 @@ concurrency:
jobs:
sync:
- if: github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_MIRROR_ENABLED == 'true'
+ if: github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_PUBLISH_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
@@ -26,7 +26,7 @@ jobs:
with:
ref: master
fetch-depth: 0
- # A PAT push triggers the mirror and Wiki workflows; GITHUB_TOKEN does not.
+ # A PAT push triggers the landing and Wiki workflows; GITHUB_TOKEN does not.
token: ${{ secrets.LANGBOT_WIKI_PUSH_TOKEN || secrets.RELEASE_UPLOAD_GITHUB_TOKEN }}
- uses: actions/setup-node@v7
with:
diff --git a/.github/workflows/sync-github-wiki.yml b/.github/workflows/sync-github-wiki.yml
index eed97553f..26502f1ab 100644
--- a/.github/workflows/sync-github-wiki.yml
+++ b/.github/workflows/sync-github-wiki.yml
@@ -57,7 +57,7 @@ jobs:
sync:
name: Publish to LangBot Wiki
- if: github.event_name != 'pull_request' && github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_MIRROR_ENABLED == 'true'
+ if: github.event_name != 'pull_request' && github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_PUBLISH_ENABLED == 'true'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 5
diff --git a/.github/workflows/sync-landing-submodule.yml b/.github/workflows/sync-landing-submodule.yml
index b6d99468a..09ced90d9 100644
--- a/.github/workflows/sync-landing-submodule.yml
+++ b/.github/workflows/sync-landing-submodule.yml
@@ -1,4 +1,4 @@
-name: Sync docs mirror and landing submodule
+name: Sync landing docs from LangBot
on:
push:
@@ -15,7 +15,7 @@ concurrency:
jobs:
sync:
- if: github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_MIRROR_ENABLED == 'true'
+ if: github.repository == 'langbot-app/LangBot' && github.ref == 'refs/heads/master' && vars.DOCS_PUBLISH_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -27,16 +27,6 @@ jobs:
- name: Test synchronization contracts
working-directory: docs
run: python3 -m unittest discover -s tests -p test_sync_landing_submodule.py -v
- - name: Publish docs subtree (fast-forward only)
- env:
- GH_TOKEN: ${{ secrets.LANGBOT_WIKI_PUSH_TOKEN || secrets.RELEASE_UPLOAD_GITHUB_TOKEN }}
- run: |
- gh auth setup-git
- git remote add docs-mirror https://github.com/langbot-app/langbot-docs.git
- git fetch docs-mirror main
- docs_sha=$(git subtree split --prefix=docs)
- git merge-base --is-ancestor docs-mirror/main "$docs_sha"
- git push docs-mirror "$docs_sha:refs/heads/main"
- name: Update landing submodule and production fork
working-directory: docs
env:
diff --git a/AGENTS.md b/AGENTS.md
index 3abf8259d..daa1c8fdd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@ Run tests in Linux to match CI; on Windows, use Linux containers through `wslc`
## Where to Look
-- Public documentation source: `docs/`; published at https://langbot.app/docs/. `langbot-app/langbot-docs` is a generated subtree mirror; edit here.
+- Public documentation source: `docs/`; published at https://langbot.app/docs/. `langbot-app/langbot-docs` is a legacy archive; edit here.
- Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 350968ea8..39a59cc1f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -348,4 +348,4 @@ When one of these changes, update the others if the behavior or contract changed
## Documentation repositories
-The public documentation site lives in `docs/` and is built with Node.js 24 and Fumapress. Internal design and review notes live in `dev-docs/`. The `langbot-app/langbot-docs` repository is a fast-forward subtree mirror for the landing site submodule and Context7. See `dev-docs/DOCS_MIGRATION.md` for publication and cutover details.
+The public documentation site lives in `docs/` and is built with Node.js 24 and Fumapress. Internal design and review notes live in `dev-docs/`. The landing site keeps its `docs` submodule path but tracks LangBot `master`, building from the nested `docs/` directory. The old `langbot-app/langbot-docs` repository can be archived; publication never writes to it. See `dev-docs/DOCS_MIGRATION.md` for publication and cutover details.
diff --git a/README.md b/README.md
index 199ddbb8a..bca0602f7 100644
--- a/README.md
+++ b/README.md
@@ -189,4 +189,4 @@ Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contr
-[Documentation source](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[Documentation source](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_CN.md b/README_CN.md
index 18a7d3426..c3849fd78 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -213,4 +213,4 @@ LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude C
If you're a code agent based on LLM, you should read [AGENTS.md](AGENTS.md) to understand the project structure and development process before you start any development.
-->
-[文档源码](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[文档源码](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_ES.md b/README_ES.md
index f0fc684e9..a57239ac1 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -187,4 +187,4 @@ Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graph
-[Código fuente de la documentación](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[Código fuente de la documentación](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_FR.md b/README_FR.md
index 33c6bcc82..ce97b49c3 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -187,4 +187,4 @@ Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/
-[Sources de la documentation](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[Sources de la documentation](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_JP.md b/README_JP.md
index a76774e67..76fb45d26 100644
--- a/README_JP.md
+++ b/README_JP.md
@@ -187,4 +187,4 @@ LangBot をより良くするために貢献してくださったすべての[
-[ドキュメントのソース](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[ドキュメントのソース](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_KO.md b/README_KO.md
index bcc9c32f7..4ffe399a6 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -187,4 +187,4 @@ LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https:
-[문서 소스](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[문서 소스](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_RU.md b/README_RU.md
index a8fd8e65c..9765c41cc 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -187,4 +187,4 @@ LangBot **дружелюбен к агентам по своей архитек
-[Исходники документации](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[Исходники документации](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_TW.md b/README_TW.md
index 0b9fc1ae6..a71aaa431 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -205,4 +205,4 @@ LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude C
-[文件原始碼](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[文件原始碼](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/README_VI.md b/README_VI.md
index 60f5354cc..5b6c9f54d 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -187,4 +187,4 @@ Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangB
-[Mã nguồn tài liệu](https://github.com/langbot-app/LangBot/tree/main/docs) · [langbot-docs mirror](https://github.com/langbot-app/langbot-docs)
+[Mã nguồn tài liệu](https://github.com/langbot-app/LangBot/tree/master/docs) · [langbot-docs archive](https://github.com/langbot-app/langbot-docs)
diff --git a/context7.json b/context7.json
new file mode 100644
index 000000000..ec3ab1dbc
--- /dev/null
+++ b/context7.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "https://context7.com/schema/context7.json",
+ "projectTitle": "LangBot",
+ "description": "Open-source platform for building AI agents and instant-messaging bots.",
+ "branch": "master",
+ "folders": ["docs/en"]
+}
diff --git a/dev-docs/DOCS_MIGRATION.md b/dev-docs/DOCS_MIGRATION.md
index 7f69df28f..7de9a43b7 100644
--- a/dev-docs/DOCS_MIGRATION.md
+++ b/dev-docs/DOCS_MIGRATION.md
@@ -1,69 +1,72 @@
-# Documentation subtree and publication
+# Documentation publication after the repository migration
-Public docs live in `LangBot/docs/`. The former internal `docs/` directory is
-`dev-docs/` (27 top-level entries, 87 tracked files at migration). Do not merge
-this migration using GitHub squash or rebase: use a merge commit to retain the
-subtree parent and original signed commits. Imported history is reachable through
-the subtree merge's second parent. Use `git log -- `
-(with the original path relative to the docs repository) to see original authors.
-A path-limited `git log docs/` or `--follow` does not reliably traverse the
-unrelated-history import. The imported commit is
-`51dddcb3f891d60bc38645e88b2dbd515344da81` (888 commits, including 452 signed
-commit objects, all retained without rewriting).
+Public docs live in `LangBot/docs/`; internal notes live in `dev-docs/`. The
+original 27 top-level internal entries (87 files) were moved without loss.
+The unsquashed subtree import retains all 888 commits, including 452 signed
+objects, from `51dddcb3f891d60bc38645e88b2dbd515344da81`. Use
+`git log 51dddcb3f891d60bc38645e88b2dbd515344da81 -- ` to inspect
+the original authors. The import is already merged; do not rewrite that history.
-## Local checks
+## Publication source
-With Node.js 24, run `npm ci`, `npm run typecheck`, `npm test`, and `npm run build`
-inside `docs/`. Output is `docs/dist/`. The site remains mounted at `/docs/` with
-canonical URL `https://langbot.app/docs`.
+`langbot-app/langbot-docs` is a historical archive, not a writable mirror.
+No publication step splits or pushes to that repository. The landing repository
+keeps its submodule **path** `docs`, but its **URL** is
+`https://github.com/langbot-app/LangBot.git`, tracking `master`. Consequently,
+the landing build runs the documentation package inside `docs/docs/` and copies
+`docs/docs/dist/public` to `public/docs`. The public URL stays
+`https://langbot.app/docs/`; Vite's base path and canonical site URL are unchanged.
-## Cutover after the PR is reviewed and merged
+The main-repository synchronization job advances only the landing gitlink to
+LangBot `master` and then synchronizes `RockChinQ/LangBot-LandingPage-Vue`.
+It checks `.gitmodules` on the exact landing revision it updates, refuses old
+repository configurations, and rejects rewinds/divergence. It never force-pushes.
-1. Pause the legacy `sync-blog-articles.yml` workflow in `langbot-app/langbot-docs`
- and let any running writer finish. Freeze direct commits to that repository.
- Pause its legacy Wiki and landing sync workflows too, avoiding duplicate
- publishers while switching ownership. Do not push disabling commits directly
- to the mirror.
-2. Fetch mirror `main` and confirm it is an ancestor of the new subtree split.
- If it advanced since import, integrate it with an **unsquashed**
- `git subtree pull --prefix=docs main` through another reviewed PR
- before cutover; reapply the inert mirror workflow files if necessary. Never
- force-push or rewrite the mirror history.
-3. The publication jobs read `LANGBOT_WIKI_PUSH_TOKEN` and fall back to the
- existing `RELEASE_UPLOAD_GITHUB_TOKEN` when that secret is absent. The fallback
- was verified on a scratch branch: it authenticates as the repository owner
- with `repo` scope and push+admin on `LangBot`, `langbot-docs`,
- `langbot-landing-page` and the production fork, and it can clone and push the
- Wiki. It does not carry the `workflow` scope, so it cannot push changes to
- `.github/workflows/**` in the mirror; the split only rewrites `docs/**`
- content, so this is fine unless a future change edits the inert mirror stubs.
- Adding a dedicated `LANGBOT_WIKI_PUSH_TOKEN` with the same access keeps the
- blast radius of the fallback credential out of the docs pipeline. Do not
- weaken branch protection to accommodate this migration.
-4. Set LangBot repository variable `DOCS_MIRROR_ENABLED=true`. Dispatch
- `sync-landing-submodule.yml` on master. It fetches full master history, runs
- `git subtree split --prefix=docs`, verifies the current mirror head is an
- ancestor, pushes without force, then advances only landing's `docs` gitlink.
- The first split replaces legacy mirror workflows with inert manual stubs.
-5. Dispatch `sync-github-wiki.yml` and `sync-blog-articles.yml` in LangBot.
- The blog job now commits only docs article paths to LangBot master using the
- dedicated token, so its push triggers the mirror and Wiki workflows.
-6. Confirm the `docs` gitlink in `langbot-app/langbot-landing-page`, then confirm
- production fork `RockChinQ/LangBot-LandingPage-Vue` matches upstream. This is
- the actual fork in the existing sync script (not the similarly named fork
- mentioned in the original migration proposal). Observe Cloudflare Pages
- deployment and check `https://langbot.app/docs/`, localized pages, assets,
- sitemap and canonical URLs. Verify Wiki pages after publication.
+## Cutover order
-The four root workflows own publication and validation. Nested workflow stubs
-are intentionally inert when split into the mirror. No direct writes should be
-made to the mirror after cutover; all edits belong in LangBot `docs/`.
+1. Merge the paired landing PR: change `.gitmodules`, the pinned `docs` gitlink,
+ and `scripts/build-embedded-docs.mjs` together. Verify its full `pnpm build`.
+2. Merge the main-repository publication changes. Keep publishers disabled until
+ both repositories are ready; the new flag is `DOCS_PUBLISH_ENABLED`, not
+ `DOCS_MIRROR_ENABLED`. The old flag does not enable these jobs.
+3. Stop the old repository's Blog, Wiki and landing workflows and let any active
+ writers finish. The owner can archive `langbot-app/langbot-docs` once the
+ new publication chain is verified. Never write disabling commits to it as
+ part of the new publication chain.
+4. Verify `LANGBOT_WIKI_PUSH_TOKEN`, or the existing `RELEASE_UPLOAD_GITHUB_TOKEN`
+ fallback, has access to LangBot, landing, production fork and Wiki. No token
+ value belongs in logs. Publication only updates gitlinks/content, never
+ workflow files in another repository, and no docs-mirror permission is needed.
+5. Set `DOCS_PUBLISH_ENABLED=true` in LangBot. Dispatch
+ `sync-landing-submodule.yml` on **master**. It also brings the production fork
+ up to date; confirm the fork matches landing main and observe Cloudflare's
+ resulting deployment before accepting the cutover.
+6. Dispatch `sync-github-wiki.yml` and `sync-blog-articles.yml` on **master**.
+ The Blog job commits only `docs/` article paths to LangBot using a token whose
+ pushes trigger subsequent landing/Wiki workflows. Wiki images and attribution
+ now point to LangBot `master/docs`, so new assets do not depend on the archive.
+7. Check `/docs/`, the en/zh/ja guide pages, assets, sitemap, Markdown exports,
+ Wiki content and Blog idempotency. A successful Wiki build is not proof of
+ publication: inspect the separate publish job.
-`docs/context7.json` continues to identify `langbot-app/langbot-docs` because that
-repository remains available with the same tree layout and original history.
-Verify Context7 ingestion after the first mirror update; no library re-registration
-is needed merely to relocate the authoring source.
+## Context7
-Disable `DOCS_MIRROR_ENABLED` to pause publishers if cutover fails. A failed
-mirror ancestry check must be investigated and reconciled, never bypassed by a
-force push. Do not restore direct mirror writers while LangBot is authoritative.
+The root `context7.json` follows the published Context7 schema and selects
+`master:docs/en`. Context7's current parser indexes English documentation.
+The old nested ownership configuration belonged to the legacy repository and
+has been removed; it does not transfer ownership of a Context7 library.
+The owner must add/refresh `https://github.com/langbot-app/LangBot` in Context7
+and verify a result from `docs/en` after merge. The existing
+`/langbot-app/langbot-docs` index is only a historical snapshot after archival.
+Do not report an index refresh as complete solely because this config exists.
+
+## Local validation and recovery
+
+Use Node.js 24. In `docs/`, run `npm ci`, `npm run typecheck`, `npm test`, and
+`npm run build`. In landing, initialize the submodule and run `pnpm install
+--frozen-lockfile`, `pnpm test`, and `pnpm build`.
+
+Disable `DOCS_PUBLISH_ENABLED` to pause publication. If landing still points at
+the legacy repository or the production fork has diverged, reconcile through
+reviewed changes; do not bypass the synchronization checks. Keep the last
+working deployment available until the replacement passes verification.
diff --git a/dev-docs/event-based-agents/adapters/qqofficial.md b/dev-docs/event-based-agents/adapters/qqofficial.md
index bf1e657a5..0206bb327 100644
--- a/dev-docs/event-based-agents/adapters/qqofficial.md
+++ b/dev-docs/event-based-agents/adapters/qqofficial.md
@@ -21,7 +21,7 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach
| Event | Evidence | Notes |
| --- | --- | --- |
-| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. |
+| `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, `GROUP_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. Full-group mode has protocol regression coverage; the earlier live probe below covered the original message path. |
| `message.reaction` | unit | `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE` map to common `MessageReactionEvent`. Live gateway evidence is pending. |
| `group.member_joined` | unit | `GUILD_MEMBER_ADD` and `GROUP_MEMBER_ADD` map to common `MemberJoinedEvent` when the gateway payload carries a group/guild/channel ID and member openid. |
| `group.member_left` | unit | `GUILD_MEMBER_REMOVE` and `GROUP_MEMBER_REMOVE` map to common `MemberLeftEvent`. Live gateway evidence is pending. |
@@ -29,6 +29,33 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach
| `bot.removed_from_group` | unit | `GUILD_DELETE` and `GROUP_DEL_ROBOT` map to common `BotRemovedFromGroupEvent`. |
| `platform.specific` | unit | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. |
+## Full-group messages
+
+After a group owner enables QQ's "receive all messages" permission, QQ delivers
+`GROUP_MESSAGE_CREATE`, including messages that mention the bot. Both the EBA
+and legacy adapters accept this event over WebSocket and unified Webhook, retain
+the sender's `member_openid` (with the older `openid` alias as fallback), and use
+the original message ID for passive group replies.
+
+Ordinary messages and images do not receive a bot `At` component. A full-group
+message receives that component only when QQ supplies a boolean `mentions[].is_you`
+equal to `true` and a matching native `<@ID>` or `<@!ID>` token in its text. Only
+the bot's own token is removed; mentions of other members remain text. Nicknames,
+`bot: true` alone, and a text that looks like a mention do not authorize a reply.
+The EBA component targets the adapter's configured bot account so existing
+`is_at_bot` route filters continue to match.
+
+Default EBA group routes requiring `is_at_bot` still exclude unmentioned group
+messages. Custom event routes or plugin subscriptions may opt into ordinary
+messages. This change does not alter QQ permissions, existing bot configuration,
+event bindings, or plugin subscriptions.
+
+`tests/unit_tests/platform/test_qqofficial_full_group_messages.py` exercises the
+real adapters and shared QQ parser with external gateway/download/send transports
+substituted. It covers text, images, self/other/unverified mentions, sender aliases,
+legacy @-only mode, private messages, and passive reply targets. This is protocol
+regression coverage, not a claim of a fresh live QQ probe of the whole upstream build.
+
## Common APIs
| API | Evidence | Notes |
diff --git a/dev-docs/platforms/http-bot.md b/dev-docs/platforms/http-bot.md
index aa07e162f..1016fca9f 100644
--- a/dev-docs/platforms/http-bot.md
+++ b/dev-docs/platforms/http-bot.md
@@ -172,7 +172,9 @@ Stitch by `session_id` + `sequence`; the turn is complete when
## 7. Reset a session
-Start a fresh conversation for a `session_id` (drops history):
+Start a fresh conversation for a `session_id`. This resets Runner conversation
+state and hides prior history from future model calls, while retaining monitoring
+records. Other sessions are unaffected; an active task must finish before reset.
```
POST /bots/{bot_uuid}/reset
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 5cd3ad0f9..fe1aecba0 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -9,6 +9,11 @@ services:
container_name: langbot_plugin_runtime
volumes:
- ./data/plugins:/app/data/plugins
+ # Persist the v4 plugin artifact cache, dependency environments, and
+ # per-installation data. Without this, recreating the container throws
+ # away every installed plugin's environment and private data while
+ # data/plugins survives.
+ - ./data/plugin-runtime:/app/data/plugin-runtime
ports:
- 5401:5401
restart: on-failure
diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml
index 53a629e20..189641a96 100644
--- a/docker/kubernetes.yaml
+++ b/docker/kubernetes.yaml
@@ -81,6 +81,25 @@ spec:
# Uncomment and modify if you have a specific StorageClass
# storageClassName: your-storage-class
+---
+# PersistentVolumeClaim for the v4 Plugin Runtime state
+# Holds the plugin artifact cache, per-plugin dependency environments, and
+# per-installation data. It must survive Pod restarts: rebuilding it forces
+# every installed plugin to be re-downloaded and its environment rebuilt.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: langbot-plugin-runtime-state
+ namespace: langbot
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+ # Uncomment and modify if you have a specific StorageClass
+ # storageClassName: your-storage-class
+
---
# ConfigMap for environment configuration
apiVersion: v1
@@ -160,6 +179,8 @@ spec:
volumeMounts:
- name: plugin-data
mountPath: /app/data/plugins
+ - name: plugin-runtime-state
+ mountPath: /app/data/plugin-runtime
resources:
requests:
memory: "512Mi"
@@ -189,6 +210,9 @@ spec:
- name: plugin-data
persistentVolumeClaim:
claimName: langbot-plugin-runtime-data
+ - name: plugin-runtime-state
+ persistentVolumeClaim:
+ claimName: langbot-plugin-runtime-state
restartPolicy: Always
---
diff --git a/docs/.gitignore b/docs/.gitignore
index 1130117ac..e58361bab 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -1,4 +1,4 @@
-node_modules/
+node_modules
.mintlify/
.DS_Store
/.omc
diff --git a/docs/README.md b/docs/README.md
index 377a8bbda..54d2e26d4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
# [LangBot](https://github.com/langbot-app/LangBot) 官方文档
-> 文档源码位于 [LangBot/docs](https://github.com/langbot-app/LangBot/tree/main/docs),[langbot-docs](https://github.com/langbot-app/langbot-docs) 仅作为发布 mirror。
+> 文档源码位于 [LangBot/docs](https://github.com/langbot-app/LangBot/tree/master/docs),[langbot-docs](https://github.com/langbot-app/langbot-docs) 将作为历史归档保留。
> [LangBot 代码仓库](https://github.com/langbot-app/LangBot)
> 这是 LangBot 4.0 的文档,3.0 文档请见 [langbot-docs 的 v3 分支](https://github.com/langbot-app/langbot-docs/tree/v3)
diff --git a/docs/README_EN.md b/docs/README_EN.md
index da4dd37d4..92f9588f4 100644
--- a/docs/README_EN.md
+++ b/docs/README_EN.md
@@ -1,4 +1,4 @@
-> Edit documentation in [LangBot/docs](https://github.com/langbot-app/LangBot/tree/main/docs). The [langbot-docs repository](https://github.com/langbot-app/langbot-docs) is a generated publication mirror. Run the commands below from `LangBot/docs/`.
+> Edit documentation in [LangBot/docs](https://github.com/langbot-app/LangBot/tree/master/docs). The [langbot-docs repository](https://github.com/langbot-app/langbot-docs) is retained as a historical archive. Run the commands below from `LangBot/docs/`.
# [LangBot](https://github.com/langbot-app/LangBot) Official Documentation
diff --git a/docs/context7.json b/docs/context7.json
deleted file mode 100644
index 2f7dafcd4..000000000
--- a/docs/context7.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "url": "https://context7.com/langbot-app/langbot-docs",
- "public_key": "pk_lYnjPxdPQabvC87DxU34F"
-}
diff --git a/docs/docs.json b/docs/docs.json
index 8cf555b54..61336fdc3 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -749,6 +749,7 @@
{
"group": "Product Updates",
"pages": [
+ "en/articles/langbot-v411-event-driven-agents",
"en/articles/langbot-cloud-multi-user-workspaces"
]
},
@@ -763,6 +764,9 @@
{
"group": "Tutorials & Integrations",
"pages": [
+ "en/articles/dify-langbot-github-release-briefing",
+ "en/articles/dify-langbot-support-triage",
+ "en/articles/dify-langbot-writing-assistant",
"en/articles/langtars-remote-pc-control-dify-n8n",
"en/articles/connect-deepseek-to-wechat",
"en/articles/deploy-ai-bot-in-5-minutes",
@@ -1076,17 +1080,10 @@
{
"group": "产品动态",
"pages": [
+ "zh/articles/langbot-v411-event-driven-agents",
"zh/articles/langbot-cloud-multi-user-workspaces"
]
},
- {
- "group": "在线体验",
- "pages": [
- "zh/articles/langbot-wecom-customer-service-knowledge-base",
- "zh/articles/langbot-telegram-sandbox-agent",
- "zh/articles/langbot-wechat-official-account"
- ]
- },
{
"group": "技术解析",
"pages": [
@@ -1098,13 +1095,19 @@
{
"group": "教程与集成",
"pages": [
+ "zh/articles/dify-langbot-github-release-briefing",
+ "zh/articles/dify-langbot-support-triage",
+ "zh/articles/dify-langbot-writing-assistant",
"zh/articles/langtars-remote-pc-control-dify-n8n",
"zh/articles/connect-deepseek-to-wechat",
"zh/articles/deploy-ai-bot-in-5-minutes",
"zh/articles/dify-agent-discord-telegram-slack",
"zh/articles/langflow-drag-and-drop-ai-bot",
"zh/articles/n8n-multi-platform-ai-chatbot",
- "zh/articles/dify-langbot-rag-knowledge-base"
+ "zh/articles/dify-langbot-rag-knowledge-base",
+ "zh/articles/langbot-telegram-sandbox-agent",
+ "zh/articles/langbot-wechat-official-account",
+ "zh/articles/langbot-wecom-customer-service-knowledge-base"
]
},
{
@@ -1386,6 +1389,7 @@
{
"group": "製品アップデート",
"pages": [
+ "ja/articles/langbot-v411-event-driven-agents",
"ja/articles/langbot-cloud-multi-user-workspaces"
]
},
@@ -1400,6 +1404,9 @@
{
"group": "チュートリアルと連携",
"pages": [
+ "ja/articles/dify-langbot-github-release-briefing",
+ "ja/articles/dify-langbot-support-triage",
+ "ja/articles/dify-langbot-writing-assistant",
"ja/articles/langtars-remote-pc-control-dify-n8n",
"ja/articles/connect-deepseek-to-wechat",
"ja/articles/deploy-ai-bot-in-5-minutes",
diff --git a/docs/en/articles/dify-langbot-github-release-briefing.mdx b/docs/en/articles/dify-langbot-github-release-briefing.mdx
new file mode 100644
index 000000000..487df6b14
--- /dev/null
+++ b/docs/en/articles/dify-langbot-github-release-briefing.mdx
@@ -0,0 +1,211 @@
+---
+title: "Turn GitHub Releases into Telegram Briefings with Dify and LangBot"
+description: "Fetch a real GitHub release with Dify, extract the useful fields and let GPT-6 Astra write a sourced briefing. Connect the Workflow to LangBot with the correct input and output contract."
+---
+
+This article is synchronized from the LangBot Blog. [Read the canonical version](https://langbot.app/en/blog/dify-langbot-github-release-briefing).
+
+Published 2026-09-16 · Author: LangBot Team
+
+“What changed in the latest release?” sounds like a simple chatbot question. It becomes a data problem as soon as a repository ships something newer than the model knows.
+
+We built an on-demand release briefing that reads GitHub first. Dify fetches the public release JSON, a code node keeps the useful fields, GPT-6 Astra writes a short summary, and LangBot returns it to the conversation. The example uses the public LangBot repository, and the same pattern works for other release feeds.
+
+
+
+*The briefing comes from a live GitHub API request, with a source link.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Create a Workflow with a clear input and output
+
+Create a Dify **Workflow** named `Astra Release Briefing`. Each request is an independent read of the current release, so a Workflow fits naturally.
+
+Connect these nodes:
+
+`User Request → HTTP Request → Code → LLM → End`
+
+
+
+*The complete five-node release workflow.*
+
+### 1. Name the input for LangBot
+
+In the Start node, add a required paragraph input named:
+
+```text
+langbot_user_message_text
+```
+
+LangBot's Dify Workflow Runner places the incoming message text in that field. If you call it `question` instead, the workflow may pass a manual Dify test and still fail when LangBot calls it with different input names.
+
+
+
+*The required input is named to match the runner contract.*
+
+### 2. Read the latest public release
+
+Use an HTTP Request node with GET:
+
+```text
+https://api.github.com/repos/langbot-app/LangBot/releases/latest
+```
+
+Add `Accept: application/vnd.github+json` and `User-Agent: LangBot-Dify-Tutorial`. This example reads public data without a GitHub token. Leave SSL verification enabled and choose sensible connection and read timeouts for your environment.
+
+
+
+*The HTTP node fetches the latest release directly from GitHub.*
+
+### 3. Extract the useful fields
+
+Pass the HTTP node's `body` and `status_code` to a Code node. Keep only the information needed for a briefing:
+
+```python
+import json
+
+def main(body: str, status_code: int) -> dict:
+ if status_code != 200:
+ return {"release": json.dumps({"error": f"GitHub HTTP {status_code}"})}
+ data = json.loads(body)
+ return {"release": json.dumps({
+ "repository": "langbot-app/LangBot",
+ "tag": data.get("tag_name"),
+ "published_at": data.get("published_at"),
+ "url": data.get("html_url"),
+ "notes": (data.get("body") or "")[:14000],
+ }, ensure_ascii=False)}
+```
+
+This removes avatars and unrelated API URLs from the model input. It also makes a non-200 response explicit. A transport failure can still stop the HTTP node before this code runs, which is why you should inspect failed workflow runs as well as successful ones.
+
+
+
+*The Code node turns the raw response into a small release record.*
+
+### 4. Write the briefing and return summary
+
+Choose GPT-6 Astra in the LLM node. Ask it to answer in the user's language and include the repository, release tag, publication time, three useful highlights, and the source URL. Tell it to use only the supplied API data and to report missing data or an error instead of guessing.
+
+Pass both `langbot_user_message_text` and the extracted `release` record into the user prompt. In End, map the LLM's `text` to an output named **`summary`**. This is the field LangBot expects from the workflow response.
+
+
+
+*The final output maps the generated text to summary.*
+
+## Verify against GitHub, not against the screenshot
+
+Our English and Chinese runs both retrieved `v4.10.11`, published on September 12, 2026 at 04:55:58 UTC. The briefing linked to that exact release and summarized changes in knowledge ingestion, monitoring, and platform compatibility.
+
+
+
+*The English run includes a release tag, date, highlights, and source.*
+
+Your run may show a newer version. Compare the answer with the current API fields—`tag_name`, `published_at`, and `html_url`—rather than expecting the version in this article forever.
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Release Briefing`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Workflow |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Ask for the briefing through LangBot
+
+The English request through LangBot returned the same kind of source-linked briefing. I also tested Chinese output. One request hit a temporary upstream SSL interruption; a later retry succeeded. The failed run remains separate from the successful evidence.
+
+
+
+*The workflow summary returned through LangBot.*
+
+For Telegram or a Discord project channel, start with an on-demand command or mention trigger. This workflow reads the release when someone asks; scheduled delivery requires an additional scheduler and sending step.
+
+To cover another repository, first duplicate the app and change the fixed API URL. Add a validated repository parameter only when you actually need it. A short, predictable integration is much easier to operate than an unrestricted URL-fetching bot.
+
+## Connect it to Telegram and Discord
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*The Telegram adapter uses a BotFather token and exposes reply options.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](https://langbot.app/en/blog/dify-langbot-writing-assistant)
+- [Route Support Questions in Discord and LINE with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-support-triage)
diff --git a/docs/en/articles/dify-langbot-support-triage.mdx b/docs/en/articles/dify-langbot-support-triage.mdx
new file mode 100644
index 000000000..6d755de54
--- /dev/null
+++ b/docs/en/articles/dify-langbot-support-triage.mdx
@@ -0,0 +1,184 @@
+---
+title: "Route Support Questions in Discord and LINE with Dify and LangBot"
+description: "Use GPT-6 Astra and a Dify Question Classifier to separate account help, product questions and requests for human review. Test each route through LangBot before connecting Discord or LINE."
+---
+
+This article is synchronized from the LangBot Blog. [Read the canonical version](https://langbot.app/en/blog/dify-langbot-support-triage).
+
+Published 2026-09-16 · Author: LangBot Team
+
+A single support prompt can work surprisingly well—until the same bot receives an expired invitation, an export question, and an angry refund request. Those messages need different responses and different limits.
+
+We built a three-route support assistant in Dify, then connected it through LangBot. GPT-6 Astra classifies the request and writes a reply for the selected route. The result is small enough to inspect: one classifier, three response nodes, and three outputs.
+
+
+
+*The actual support Chatflow, with separate account, product, and human-review routes.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Define what each route is allowed to do
+
+This example uses a fictional help-desk product called Northstar Desk. Its few product rules are included directly in the branch prompts, so you do not need a pre-existing knowledge base or another tutorial.
+
+| Route | Requests | Job |
+| --- | --- | --- |
+| Account | Login, invitations, workspace access | Explain the invitation recovery steps |
+| Product | CSV export, trial limits, basic setup | Answer from the small set of supplied product rules |
+| Human | Billing, refunds, enterprise pricing, complaints, unclear mixed requests | Explain that a human decision is needed and help the user prepare a concise request |
+
+The Human branch produces handoff guidance. It does not create a ticket, notify an employee, or approve a refund. That distinction belongs in the prompt: otherwise the assistant may casually imply it has performed an action that the workflow never implemented.
+
+## Build the classifier and three branches
+
+Create a **Chatflow** named `Astra Support Router`. Connect User Input to a **Question Classifier**, choose GPT-6 Astra, and set its query variable to `sys.query`.
+
+Add these categories:
+
+```text
+Account: login, invitation, password or workspace access
+Product: usage, ticket export, setup and trial limits
+Human: billing, refunds, enterprise pricing, angry complaints
+or unclear mixed questions
+```
+
+
+
+*The category descriptions define the routing boundary.*
+
+In the classifier's advanced instructions, tell it to choose Human when essential context is missing or the request needs authorization. Connect each branch to its own LLM node and Answer node.
+
+Use GPT-6 Astra in each LLM node and pass the user query through `sys.query`. Give the branches different system instructions:
+
+- **Account Reply:** invitations expire after 48 hours. Ask the workspace owner to resend from `Settings > Members`. Never request passwords. Ask one clarifying question if the problem is different.
+- **Product Reply:** owners export CSV tickets from `Settings > Data > Export`; export links last 7 days. The trial lasts 14 days and supports 5 teammates and 2 inboxes. Say when the supplied notes do not cover a question.
+- **Human Reply:** acknowledge the request and explain that a human must decide. Ask for a brief issue summary and a non-sensitive reference. Tell the user to contact their support team, without inventing an address, response-time promise, or completed action.
+
+All three prompts ask for concise replies in the user's language. Keeping the branch instructions separate makes a later change easier to review: adjusting billing guidance does not also change the login instructions.
+
+## Test the path, not only the wording
+
+We ran an expired-invitation question, a CSV-export question, and a refund complaint. The preview identified the selected output for each run:
+
+| Test | Observed output | Response |
+| --- | --- | --- |
+| Expired invitation | Account Output | Owner resend instructions and the 48-hour limit |
+| Export tickets as CSV | Product Output | Correct menu path and 7-day availability |
+| Refund complaint | Human Output | Human-review guidance without claiming a refund was issued |
+
+
+
+*The account test follows the Account branch.*
+
+
+
+*The export question follows the Product branch.*
+
+Expand the workflow process in Preview when a result looks odd. A plausible sentence can hide an incorrect route. That is why the output name is part of the test, rather than treating every fluent answer as a pass.
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Support Router`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Run it through LangBot
+
+The same expired-invitation question in LangBot returned the workspace-owner recovery steps. A later refund request in Chinese reached the human-review guidance. LangBot did not need its own copy of the classification rules.
+
+
+
+*An account-support response returned through LangBot.*
+
+Before putting this in a busy Discord server or a LINE support account, add ambiguous examples such as “I cannot log in and want a refund.” If those repeatedly land in Product, refine the category descriptions and classifier instructions.
+
+When you are ready for a real handoff, add your existing ticketing or notification API to the Human branch and handle its success and failure explicitly. The example gives you a visible place to add that action later.
+
+## Connect it to Discord and LINE
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*LINE requires its channel access token and channel secret.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](https://langbot.app/en/blog/dify-langbot-writing-assistant)
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-github-release-briefing)
diff --git a/docs/en/articles/dify-langbot-writing-assistant.mdx b/docs/en/articles/dify-langbot-writing-assistant.mdx
new file mode 100644
index 000000000..1e1cb76e5
--- /dev/null
+++ b/docs/en/articles/dify-langbot-writing-assistant.mdx
@@ -0,0 +1,177 @@
+---
+title: "A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot"
+description: "Build a basic Dify Chatbot for announcements, revisions and translations, then connect it to LangBot. Includes tested prompts, screenshots and Discord and Telegram setup guidance."
+---
+
+This article is synchronized from the LangBot Blog. [Read the canonical version](https://langbot.app/en/blog/dify-langbot-writing-assistant).
+
+Published 2026-09-16 · Author: LangBot Team
+
+A community announcement usually starts as a few rough notes: a date, a channel, and something people need to bring. The first draft is easy. The annoying part is reopening another app for every “make it shorter” or “turn that into a Telegram reminder.”
+
+We built a small writing assistant that keeps those revisions in the conversation. Dify owns the prompt and chat history, GPT-6 Astra writes the response, and LangBot provides the messaging-platform entry point. Here is the complete setup, including the local deployment and the replies We tested.
+
+
+
+*A real response from Dify and Astra, shown in LangBot Debug Chat.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Build a basic Chatbot
+
+In Dify Studio, choose **Create → Create from Blank → More basic app types → Chatbot**. Name it `Astra Writing Assistant`. A basic Chatbot is enough here: we need a clear prompt and conversation history, without a branching workflow.
+
+
+
+*Dify keeps its basic Chatbot option under More basic app types.*
+
+
+
+*Give the assistant a name and a specific job.*
+
+Select GPT-6 Astra under **Configure model**, then add these instructions:
+
+```text
+You help a community manager turn rough notes into messages people can use.
+Reply in the language of the user's request.
+For an announcement, give: one clear headline, a short ready-to-send message,
+and one optional reminder.
+Preserve supplied dates, times, links, and requirements exactly.
+Never invent missing event details, discounts, results, or promises.
+If an essential detail is missing, ask one concise question.
+Use a natural, direct tone. Avoid hype and excessive emojis.
+Keep a typical reply under 180 words. When revising, use conversation context.
+```
+
+The most useful line is the one about preserving details. An announcement can survive plain wording. It cannot survive an invented RSVP link or the wrong time zone. We also kept the output short enough to be useful in a channel.
+
+
+
+*The prompt gives the assistant a narrow job and clear boundaries.*
+
+For the first test, we entered:
+
+> Write a Discord announcement: community demo day is September 22 at 18:00 UTC. Bring one bot demo. RSVP in #demo-day. Keep it friendly and under 100 words.
+
+The response preserved the date, time, and channel. We then asked, “Make it shorter, and remove the optional reminder.” The second reply kept the details from the first turn without asking us to paste them again.
+
+
+
+*The first English announcement generated in Dify.*
+
+
+
+*A follow-up instruction revises the existing announcement.*
+
+Publish the updated configuration with **Publish Update**. This step matters: an external caller should receive the version you just tested, rather than an older prompt still attached to the published app.
+
+
+
+*The published app exposes a backend API for LangBot.*
+
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Writing Assistant`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Test the same task through LangBot
+
+We asked LangBot for a two-sentence Telegram reminder. The reply came back through Dify with the supplied date and time intact. A later request switched the same conversation to Chinese, which also worked.
+
+For a community server, we would start with mention-based triggers in a single channel. Let members ask for a draft, shorten it, and adjust the tone before they decide to post it. That small loop is already useful for release announcements, event reminders, and repeated support replies.
+
+
+
+*The writing assistant answering through the LangBot pipeline.*
+
+
+## Connect it to Discord and Telegram
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*The Telegram adapter uses a BotFather token and exposes reply options.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [Route Support Questions in Discord and LINE with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-support-triage)
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-github-release-briefing)
diff --git a/docs/en/articles/index.mdx b/docs/en/articles/index.mdx
index a06650975..6e40c6f0a 100644
--- a/docs/en/articles/index.mdx
+++ b/docs/en/articles/index.mdx
@@ -7,6 +7,7 @@ Product updates, engineering deep dives, tutorials, integrations, and announceme
## Product Updates
+- [LangBot 4.11: Event-Driven Agents, Plugin Runners, and a Workspace Assistant](/en/articles/langbot-v411-event-driven-agents) — 2026-10-15
- [The new LangBot Cloud is here — and multi-user Workspaces are now in open source](/en/articles/langbot-cloud-multi-user-workspaces) — 2026-08-25
## Engineering
@@ -17,6 +18,9 @@ Product updates, engineering deep dives, tutorials, integrations, and announceme
## Tutorials & Integrations
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](/en/articles/dify-langbot-github-release-briefing) — 2026-09-16
+- [Route Support Questions in Discord and LINE with Dify and LangBot](/en/articles/dify-langbot-support-triage) — 2026-09-16
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](/en/articles/dify-langbot-writing-assistant) — 2026-09-16
- [LangTARS: Open-Source AI Agent for Remote PC Control — Works with Dify, n8n & 10+ Messaging Platforms](/en/articles/langtars-remote-pc-control-dify-n8n) — 2026-03-23
- [How to Connect DeepSeek R1 to WeChat, Discord & Telegram in 5 Minutes (FREE)](/en/articles/connect-deepseek-to-wechat) — 2026-02-21
- [Deploy Your Own AI Bot to Discord, Telegram & WeChat in 5 Minutes](/en/articles/deploy-ai-bot-in-5-minutes) — 2026-02-17
diff --git a/docs/en/articles/langbot-v411-event-driven-agents.mdx b/docs/en/articles/langbot-v411-event-driven-agents.mdx
new file mode 100644
index 000000000..2f861da2c
--- /dev/null
+++ b/docs/en/articles/langbot-v411-event-driven-agents.mdx
@@ -0,0 +1,132 @@
+---
+title: "LangBot 4.11: Event-Driven Agents, Plugin Runners, and a Workspace Assistant"
+description: "Explore LangBot 4.11 with real screenshots: route platform events to agents, install independent AgentRunner plugins, manage resources through the Workspace Assistant, and upgrade existing pipelines."
+---
+
+This article is synchronized from the LangBot Blog. [Read the canonical version](https://langbot.app/en/blog/langbot-v411-event-driven-agents).
+
+Published 2026-10-15 · Author: LangBot Team
+
+LangBot is an open-source AI chatbot and agent platform that connects messaging platforms, large language models, knowledge bases, and plugins to help you build, deploy, and manage AI assistants.
+
+Most chatbots start with a simple loop: receive a message, generate a reply. Real collaboration involves more than messages, though. A new member joins a community, someone reacts to an answer, or a colleague clicks a card button. Each can be the beginning of a task.
+
+**LangBot 4.11 makes these events configurable entry points for agents, with routing and execution tracking.** Together with plugin-based AgentRunners and the new Workspace Assistant, the release separates three practical questions: what triggers the work, what executes it, and how you manage it.
+
+## 1. Event-driven agents: more than the next message
+
+Event-Based Agents, or EBA, extend processing beyond ordinary messages to platform events such as member joins and departures, message recalls, reactions, and card clicks. In this release, 13 platform adapters have been rebuilt on the EBA architecture.
+
+That does not mean every platform offers identical event coverage. Available events still depend on the platform, adapter, and bot permissions. The shared foundation is what happens after an event reaches LangBot: it can be routed to a suitable processor.
+
+
+
+*Event Routing example from the official 4.11 release notes.*
+
+### Start with the scenario
+
+Event Routing provides common scenarios, such as welcoming new members or handling departures, as starting points for rules. You then choose the processor responsible for the work.
+
+For example, a community could send ordinary questions to a knowledge-base Pipeline, route joins to an Agent that welcomes members and explains community rules, and use a plugin processor for events requiring fixed business logic. This is a configuration pattern, not a preconfigured integration: check platform support and permissions, then test each route before enabling it.
+
+The benefit is a cleaner separation between platform connectivity and business behavior. You can revise the welcome flow without rewriting the chatbot integration, or change the question-answering strategy without combining every behavior into one prompt.
+
+### Pipeline, Agent, or plugin processor?
+
+
+
+*The processor creation screen in the fresh LangBot 4.11.0 instance used for this article.*
+
+- **Pipeline:** handles message events through a fixed sequence. A good fit for direct AI replies, knowledge-base questions, and established message-processing flows.
+- **Agent:** follows natural-language instructions to handle different events, or delegates processing to an external agent platform. Useful when a task requires contextual decisions.
+- **Plugin processor:** runs plugin code for explicitly declared event types. A good fit for deterministic rules and specialized integrations.
+
+Not everything needs to become an agent. Keep clear rules in code, predictable question-answering in a Pipeline, and use an Agent where judgment adds value.
+
+### Debug the event, not just the reply
+
+The event debugger lets you edit an event payload through a form and inspect execution logs and details. It helps validate fields and processing behavior before testing on the real messaging platform. A successful debug run does not replace verification of platform connectivity, permissions, and actual callbacks.
+
+Platform improvements accompany the new architecture, including Slack Socket Mode, DingTalk card interactions, WeChat typing indicators, and OneBot WebSocket support. Check the relevant platform documentation for the exact capabilities and setup requirements.
+
+## 2. Plugin-based AgentRunners: update execution independently
+
+LocalAgent, Dify, Coze, and n8n runners have moved from the core application into official Marketplace plugins. New runners include Codex, Claude Code, and ACP.
+
+
+
+*The Official Runner section loaded from the Marketplace in our running instance. Marketplace listings evolve independently of the Core release.*
+
+For users, the practical change is straightforward: **install the runners you need and update them independently**. Use LocalAgent for models, tools, and knowledge retrieval, or connect an existing Dify application or n8n workflow without building a separate messaging integration for each execution backend.
+
+For developers, AgentRunner Protocol v1 provides an extension boundary for implementing a runner without adding platform-specific execution logic directly to Core.
+
+
+
+*Runner examples from the official release notes. Each runner still has its own account, API, and execution-environment requirements.*
+
+Plugin-based execution is not unrestricted execution. Runners can access only the tools and platform APIs authorized in the current context. The Marketplace displays certification badges, and Core verifies certified plugin signatures. These mechanisms address provenance and authorization; still read the plugin documentation and check its compatibility with your deployment.
+
+### What happens to existing Pipelines?
+
+The migration flow installs required plugins and backs up the original configuration. After upgrading, complete migration on the Pipelines page, then review the selected runner, model, knowledge bases, and tools.
+
+**Migrated Pipelines start fresh sessions.** Legacy custom runners must be rewritten as plugin runners. Offline deployments can choose **Data only** migration and install the runner plugins manually. The [4.11 configuration migration map](https://github.com/langbot-app/LangBot/blob/v4.11.0/docs/pipeline-migration-config-map.zh-CN.md) provides the detailed mappings in Chinese.
+
+## 3. Workspace Assistant: configuration through confirmed actions
+
+The new Workspace Assistant is for the people managing LangBot, rather than end users chatting with a bot. It can inspect resources in the current Workspace and help create or configure resources such as Pipelines and knowledge bases through conversation.
+
+
+
+*The assistant's initial screen in our fresh instance. A LangBot Account or a configured model is required before use. The zero-value monitoring data is the genuine empty state of this new Workspace.*
+
+Start with a read-only question such as “What models, knowledge bases and Pipelines are available?” Then ask for a task such as creating a knowledge-base question-answering application. The useful distinction is not merely replacing a form with a chat box: the assistant can inspect existing resources before proposing a configuration.
+
+**Every write requires confirmation before execution.** Review the proposed resource, parameters, and scope before approving a change. This makes configuration easier to find while keeping the administrator in control of modifications.
+
+## 4. Operations that match the new execution model
+
+### Follow executions, not only message counts
+
+With events going to Pipelines, Agents, or plugins, debugging requires a view of the whole execution. LangBot 4.11 adds unified execution views, tool-call tracing, and a live in-flight panel.
+
+A useful troubleshooting sequence is to check whether the event arrived, which processor received it, and whether model and tool calls completed. This separates connectivity problems from routing mistakes and execution failures instead of treating every missing reply as the same issue.
+
+### Account security and an operation trail
+
+
+
+*The account settings in our running instance. Passkeys and two-factor authentication are not enabled on this demonstration account.*
+
+The release adds WebAuthn passkey sign-in and TOTP two-factor authentication, alongside operation logs that record changes and support integrity checks. In a shared Workspace, these features help strengthen sign-in and establish who changed what. Passkeys also depend on browser secure-context requirements; configure them on an appropriate HTTPS deployment.
+
+Media handling now uses content-addressed, deduplicated storage to reduce growth of `langbot.db` caused by repeated media. For command-line operations, `lbctl` and API keys provide management and diagnostic entry points that can fit into existing operational workflows.
+
+## 5. A clearer first run—and an upgrade checklist
+
+
+
+*The live onboarding wizard starts with a platform, then moves through bot setup and AI engine configuration.*
+
+New users can follow the sidebar tour and scenario-based bot wizard. Validate a simple question-and-answer flow first, then add knowledge retrieval, tools, and event routes. Introducing one capability at a time makes failures easier to understand.
+
+For an existing deployment:
+
+1. **Back up first.** Preserve `data/` or your PostgreSQL database and deployment configuration. Upgrading from the latest v4.10.x is recommended.
+2. **Upgrade Core and its matching Runtime.** Check service connectivity. The 4.11.0 release pins SDK `0.7.11`; do not leave an older Runtime in place.
+3. **Migrate Pipelines and update plugins.** Review the resulting runner configuration and remember the fresh-session boundary.
+4. **Verify a real business flow.** Test an existing message conversation before expanding to event routes and authorized tools.
+5. **Review telemetry and configuration changes.** New execution telemetry is enabled by default and excludes message content; disable that telemetry with `space.disable_telemetry: true`. Configuration adds `media_cache`, `api.host`, and `runner.admin_plugins`, and removes `force_box_session_id_template`. See the [data collection policy](https://langbot.app/docs/en/insight/data-collection-policy) and release notes.
+
+## Get started with LangBot 4.11
+
+The release connects three decisions: **which event starts a task, which runner or processor handles it, and how to manage and observe the result**. Keep familiar chat-based flows where they work, then introduce community interactions and event-driven tasks incrementally.
+
+- [Download v4.11.0 and read the full release notes](https://github.com/langbot-app/LangBot/releases/tag/v4.11.0)
+- [Read the deployment and usage documentation](https://langbot.app/docs/en/insight/guide)
+- [Try LangBot Cloud](https://cloud.langbot.app)
+
+Thanks to everyone who contributed code, testing, documentation, and feedback. This article focuses on the main product changes; the complete contributor list is in the release notes.
+
+*Image sources: the event-routing and runner-overview images come from the v4.11.0 GitHub Release. The other five screenshots were captured from an independently launched official `rockchin/langbot:v4.11.0` instance. Screenshots use English for consistent cross-language reference. No production bots were connected, and the demo screens are not presented as evidence of production task execution.*
diff --git a/docs/en/plugin/dev/tutor.mdx b/docs/en/plugin/dev/tutor.mdx
index 9b8e26926..84626ba66 100644
--- a/docs/en/plugin/dev/tutor.mdx
+++ b/docs/en/plugin/dev/tutor.mdx
@@ -111,7 +111,7 @@ You need to first deploy and start LangBot, ensuring that Plugin Runtime is runn
-- `Stdio` mode: When you start LangBot using source code without the startup parameter `--standalone-runtime`, LangBot will automatically start Plugin Runtime as a subprocess and communicate with Plugin Runtime through `stdio` (standard input/output streams). In this case, Runtime will load plugins from the `data/plugins` directory in the LangBot root directory and listen on port `5401` of the LangBot host as a debug port.
+- `Stdio` mode: When you start LangBot using source code without the startup parameter `--standalone-runtime`, LangBot will automatically start Plugin Runtime as a subprocess and communicate with Plugin Runtime through `stdio` (standard input/output streams). In this case, Runtime will load plugins from the `data/plugins` directory in the LangBot root directory and listen on the LangBot host debug port (default `5401`, overridable via the port in `plugin.display_plugin_debug_url`).
- `WebSocket` mode:
- Production environment: When you start LangBot using the official `docker-compose.yaml`, Plugin Runtime will run in a separate container, and LangBot will communicate with Plugin Runtime in WebSocket mode due to the startup parameter `--standalone-runtime`. The 5401 port of the Runtime container will be mapped to the 5401 port of the host as default.
diff --git a/docs/images/articles/source/images/dify-langbot/00-dify-install.png b/docs/images/articles/source/images/dify-langbot/00-dify-install.png
new file mode 100644
index 000000000..29a31d96d
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/00-dify-install.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/00-langbot-install.png b/docs/images/articles/source/images/dify-langbot/00-langbot-install.png
new file mode 100644
index 000000000..2894c652e
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/00-langbot-install.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/007-langbot-dify-runner-config.png b/docs/images/articles/source/images/dify-langbot/007-langbot-dify-runner-config.png
new file mode 100644
index 000000000..563d3e520
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/007-langbot-dify-runner-config.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/010-dify-create-basic-app.png b/docs/images/articles/source/images/dify-langbot/010-dify-create-basic-app.png
new file mode 100644
index 000000000..5311d8677
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/010-dify-create-basic-app.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/011-dify-writing-app-create.png b/docs/images/articles/source/images/dify-langbot/011-dify-writing-app-create.png
new file mode 100644
index 000000000..d39461234
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/011-dify-writing-app-create.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/012-dify-writing-instructions.png b/docs/images/articles/source/images/dify-langbot/012-dify-writing-instructions.png
new file mode 100644
index 000000000..cf687d355
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/012-dify-writing-instructions.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/014-dify-astra-model-settings.png b/docs/images/articles/source/images/dify-langbot/014-dify-astra-model-settings.png
new file mode 100644
index 000000000..ea6ad3cc4
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/014-dify-astra-model-settings.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/016-writing-english-first-reply.png b/docs/images/articles/source/images/dify-langbot/016-writing-english-first-reply.png
new file mode 100644
index 000000000..9ebb6d2ce
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/016-writing-english-first-reply.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/019-writing-english-context-revision.png b/docs/images/articles/source/images/dify-langbot/019-writing-english-context-revision.png
new file mode 100644
index 000000000..3d7d83fc3
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/019-writing-english-context-revision.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/022-writing-chinese-first-reply.png b/docs/images/articles/source/images/dify-langbot/022-writing-chinese-first-reply.png
new file mode 100644
index 000000000..5ae4f86d7
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/022-writing-chinese-first-reply.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/023-writing-access-point-api.png b/docs/images/articles/source/images/dify-langbot/023-writing-access-point-api.png
new file mode 100644
index 000000000..6bfb627b3
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/023-writing-access-point-api.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/025-langbot-writing-english-roundtrip.png b/docs/images/articles/source/images/dify-langbot/025-langbot-writing-english-roundtrip.png
new file mode 100644
index 000000000..7764a069b
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/025-langbot-writing-english-roundtrip.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/026-langbot-writing-chinese-roundtrip.png b/docs/images/articles/source/images/dify-langbot/026-langbot-writing-chinese-roundtrip.png
new file mode 100644
index 000000000..3553504c2
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/026-langbot-writing-chinese-roundtrip.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/039-triage-three-branch-overview.png b/docs/images/articles/source/images/dify-langbot/039-triage-three-branch-overview.png
new file mode 100644
index 000000000..c5ed45d49
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/039-triage-three-branch-overview.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/040-triage-classifier-settings.png b/docs/images/articles/source/images/dify-langbot/040-triage-classifier-settings.png
new file mode 100644
index 000000000..420c84547
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/040-triage-classifier-settings.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/041-triage-account-path-result.png b/docs/images/articles/source/images/dify-langbot/041-triage-account-path-result.png
new file mode 100644
index 000000000..dd1759725
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/041-triage-account-path-result.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/043-triage-product-path-result.png b/docs/images/articles/source/images/dify-langbot/043-triage-product-path-result.png
new file mode 100644
index 000000000..12dec24ae
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/043-triage-product-path-result.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/045-release-workflow-overview.png b/docs/images/articles/source/images/dify-langbot/045-release-workflow-overview.png
new file mode 100644
index 000000000..154c32cfa
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/045-release-workflow-overview.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/046-release-http-request-settings.png b/docs/images/articles/source/images/dify-langbot/046-release-http-request-settings.png
new file mode 100644
index 000000000..6b0436bd6
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/046-release-http-request-settings.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/047-release-code-extraction-node.png b/docs/images/articles/source/images/dify-langbot/047-release-code-extraction-node.png
new file mode 100644
index 000000000..c2e005c05
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/047-release-code-extraction-node.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/049-release-workflow-input.png b/docs/images/articles/source/images/dify-langbot/049-release-workflow-input.png
new file mode 100644
index 000000000..1d81851a3
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/049-release-workflow-input.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/050-triage-human-review-chinese.png b/docs/images/articles/source/images/dify-langbot/050-triage-human-review-chinese.png
new file mode 100644
index 000000000..8e0513c97
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/050-triage-human-review-chinese.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/053-langbot-triage-dify-settings.png b/docs/images/articles/source/images/dify-langbot/053-langbot-triage-dify-settings.png
new file mode 100644
index 000000000..5d300fd19
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/053-langbot-triage-dify-settings.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/054-release-summary-output-verified.png b/docs/images/articles/source/images/dify-langbot/054-release-summary-output-verified.png
new file mode 100644
index 000000000..e7286b041
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/054-release-summary-output-verified.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/055-langbot-triage-account-english.png b/docs/images/articles/source/images/dify-langbot/055-langbot-triage-account-english.png
new file mode 100644
index 000000000..759b2c74f
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/055-langbot-triage-account-english.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/056-release-english-live-briefing.png b/docs/images/articles/source/images/dify-langbot/056-release-english-live-briefing.png
new file mode 100644
index 000000000..c06306712
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/056-release-english-live-briefing.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/059-platform-discord-fields.png b/docs/images/articles/source/images/dify-langbot/059-platform-discord-fields.png
new file mode 100644
index 000000000..13f1357f6
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/059-platform-discord-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/060-platform-telegram-fields.png b/docs/images/articles/source/images/dify-langbot/060-platform-telegram-fields.png
new file mode 100644
index 000000000..a9534d81b
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/060-platform-telegram-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/061-platform-line-fields.png b/docs/images/articles/source/images/dify-langbot/061-platform-line-fields.png
new file mode 100644
index 000000000..77927e6b0
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/061-platform-line-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/062-platform-lark-fields.png b/docs/images/articles/source/images/dify-langbot/062-platform-lark-fields.png
new file mode 100644
index 000000000..bdb2665f4
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/062-platform-lark-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/063-platform-dingtalk-fields.png b/docs/images/articles/source/images/dify-langbot/063-platform-dingtalk-fields.png
new file mode 100644
index 000000000..e58a12db5
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/063-platform-dingtalk-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/064-platform-wecombot-fields.png b/docs/images/articles/source/images/dify-langbot/064-platform-wecombot-fields.png
new file mode 100644
index 000000000..f2e2593d3
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/064-platform-wecombot-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/066-platform-onebot-v11-fields.png b/docs/images/articles/source/images/dify-langbot/066-platform-onebot-v11-fields.png
new file mode 100644
index 000000000..275b02cd5
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/066-platform-onebot-v11-fields.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/075-release-chinese-live-briefing.png b/docs/images/articles/source/images/dify-langbot/075-release-chinese-live-briefing.png
new file mode 100644
index 000000000..03160a3be
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/075-release-chinese-live-briefing.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/076-langbot-triage-human-chinese.png b/docs/images/articles/source/images/dify-langbot/076-langbot-triage-human-chinese.png
new file mode 100644
index 000000000..33498126a
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/076-langbot-triage-human-chinese.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/078-langbot-release-workflow-settings.png b/docs/images/articles/source/images/dify-langbot/078-langbot-release-workflow-settings.png
new file mode 100644
index 000000000..1896047b0
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/078-langbot-release-workflow-settings.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/079-langbot-release-english-roundtrip.png b/docs/images/articles/source/images/dify-langbot/079-langbot-release-english-roundtrip.png
new file mode 100644
index 000000000..038576e70
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/079-langbot-release-english-roundtrip.png differ
diff --git a/docs/images/articles/source/images/dify-langbot/082-release-chinese-reply-detail.png b/docs/images/articles/source/images/dify-langbot/082-release-chinese-reply-detail.png
new file mode 100644
index 000000000..74bfae31b
Binary files /dev/null and b/docs/images/articles/source/images/dify-langbot/082-release-chinese-reply-detail.png differ
diff --git a/docs/images/articles/source/images/langbot-411/account-security.png b/docs/images/articles/source/images/langbot-411/account-security.png
new file mode 100644
index 000000000..86174dd0e
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/account-security.png differ
diff --git a/docs/images/articles/source/images/langbot-411/eba-event-routing.png b/docs/images/articles/source/images/langbot-411/eba-event-routing.png
new file mode 100644
index 000000000..054dd6a12
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/eba-event-routing.png differ
diff --git a/docs/images/articles/source/images/langbot-411/marketplace-live.png b/docs/images/articles/source/images/langbot-411/marketplace-live.png
new file mode 100644
index 000000000..a75090493
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/marketplace-live.png differ
diff --git a/docs/images/articles/source/images/langbot-411/onboarding.png b/docs/images/articles/source/images/langbot-411/onboarding.png
new file mode 100644
index 000000000..9c70ea572
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/onboarding.png differ
diff --git a/docs/images/articles/source/images/langbot-411/processor-types.png b/docs/images/articles/source/images/langbot-411/processor-types.png
new file mode 100644
index 000000000..adfc1d4c4
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/processor-types.png differ
diff --git a/docs/images/articles/source/images/langbot-411/runner-plugins-marketplace.png b/docs/images/articles/source/images/langbot-411/runner-plugins-marketplace.png
new file mode 100644
index 000000000..5814d27b8
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/runner-plugins-marketplace.png differ
diff --git a/docs/images/articles/source/images/langbot-411/workspace-assistant.png b/docs/images/articles/source/images/langbot-411/workspace-assistant.png
new file mode 100644
index 000000000..08bc40c49
Binary files /dev/null and b/docs/images/articles/source/images/langbot-411/workspace-assistant.png differ
diff --git a/docs/ja/articles/dify-langbot-github-release-briefing.mdx b/docs/ja/articles/dify-langbot-github-release-briefing.mdx
new file mode 100644
index 000000000..132e422e5
--- /dev/null
+++ b/docs/ja/articles/dify-langbot-github-release-briefing.mdx
@@ -0,0 +1,213 @@
+---
+title: "Turn GitHub Releases into Telegram Briefings with Dify and LangBot"
+description: "Fetch a real GitHub release with Dify, extract the useful fields and let GPT-6 Astra write a sourced briefing. Connect the Workflow to LangBot with the correct input and output contract."
+---
+
+この記事は LangBot Blog から同期されています。 [Blog の原文を読む](https://langbot.app/en/blog/dify-langbot-github-release-briefing).
+
+公開日:2026-09-16 · 著者:LangBot Team
+
+> この記事の日本語版はまだ公開されていないため、英語版を掲載しています。
+
+“What changed in the latest release?” sounds like a simple chatbot question. It becomes a data problem as soon as a repository ships something newer than the model knows.
+
+We built an on-demand release briefing that reads GitHub first. Dify fetches the public release JSON, a code node keeps the useful fields, GPT-6 Astra writes a short summary, and LangBot returns it to the conversation. The example uses the public LangBot repository, and the same pattern works for other release feeds.
+
+
+
+*The briefing comes from a live GitHub API request, with a source link.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Create a Workflow with a clear input and output
+
+Create a Dify **Workflow** named `Astra Release Briefing`. Each request is an independent read of the current release, so a Workflow fits naturally.
+
+Connect these nodes:
+
+`User Request → HTTP Request → Code → LLM → End`
+
+
+
+*The complete five-node release workflow.*
+
+### 1. Name the input for LangBot
+
+In the Start node, add a required paragraph input named:
+
+```text
+langbot_user_message_text
+```
+
+LangBot's Dify Workflow Runner places the incoming message text in that field. If you call it `question` instead, the workflow may pass a manual Dify test and still fail when LangBot calls it with different input names.
+
+
+
+*The required input is named to match the runner contract.*
+
+### 2. Read the latest public release
+
+Use an HTTP Request node with GET:
+
+```text
+https://api.github.com/repos/langbot-app/LangBot/releases/latest
+```
+
+Add `Accept: application/vnd.github+json` and `User-Agent: LangBot-Dify-Tutorial`. This example reads public data without a GitHub token. Leave SSL verification enabled and choose sensible connection and read timeouts for your environment.
+
+
+
+*The HTTP node fetches the latest release directly from GitHub.*
+
+### 3. Extract the useful fields
+
+Pass the HTTP node's `body` and `status_code` to a Code node. Keep only the information needed for a briefing:
+
+```python
+import json
+
+def main(body: str, status_code: int) -> dict:
+ if status_code != 200:
+ return {"release": json.dumps({"error": f"GitHub HTTP {status_code}"})}
+ data = json.loads(body)
+ return {"release": json.dumps({
+ "repository": "langbot-app/LangBot",
+ "tag": data.get("tag_name"),
+ "published_at": data.get("published_at"),
+ "url": data.get("html_url"),
+ "notes": (data.get("body") or "")[:14000],
+ }, ensure_ascii=False)}
+```
+
+This removes avatars and unrelated API URLs from the model input. It also makes a non-200 response explicit. A transport failure can still stop the HTTP node before this code runs, which is why you should inspect failed workflow runs as well as successful ones.
+
+
+
+*The Code node turns the raw response into a small release record.*
+
+### 4. Write the briefing and return summary
+
+Choose GPT-6 Astra in the LLM node. Ask it to answer in the user's language and include the repository, release tag, publication time, three useful highlights, and the source URL. Tell it to use only the supplied API data and to report missing data or an error instead of guessing.
+
+Pass both `langbot_user_message_text` and the extracted `release` record into the user prompt. In End, map the LLM's `text` to an output named **`summary`**. This is the field LangBot expects from the workflow response.
+
+
+
+*The final output maps the generated text to summary.*
+
+## Verify against GitHub, not against the screenshot
+
+Our English and Chinese runs both retrieved `v4.10.11`, published on September 12, 2026 at 04:55:58 UTC. The briefing linked to that exact release and summarized changes in knowledge ingestion, monitoring, and platform compatibility.
+
+
+
+*The English run includes a release tag, date, highlights, and source.*
+
+Your run may show a newer version. Compare the answer with the current API fields—`tag_name`, `published_at`, and `html_url`—rather than expecting the version in this article forever.
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Release Briefing`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Workflow |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Ask for the briefing through LangBot
+
+The English request through LangBot returned the same kind of source-linked briefing. I also tested Chinese output. One request hit a temporary upstream SSL interruption; a later retry succeeded. The failed run remains separate from the successful evidence.
+
+
+
+*The workflow summary returned through LangBot.*
+
+For Telegram or a Discord project channel, start with an on-demand command or mention trigger. This workflow reads the release when someone asks; scheduled delivery requires an additional scheduler and sending step.
+
+To cover another repository, first duplicate the app and change the fixed API URL. Add a validated repository parameter only when you actually need it. A short, predictable integration is much easier to operate than an unrestricted URL-fetching bot.
+
+## Connect it to Telegram and Discord
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*The Telegram adapter uses a BotFather token and exposes reply options.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](https://langbot.app/en/blog/dify-langbot-writing-assistant)
+- [Route Support Questions in Discord and LINE with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-support-triage)
diff --git a/docs/ja/articles/dify-langbot-support-triage.mdx b/docs/ja/articles/dify-langbot-support-triage.mdx
new file mode 100644
index 000000000..9c1a5f019
--- /dev/null
+++ b/docs/ja/articles/dify-langbot-support-triage.mdx
@@ -0,0 +1,186 @@
+---
+title: "Route Support Questions in Discord and LINE with Dify and LangBot"
+description: "Use GPT-6 Astra and a Dify Question Classifier to separate account help, product questions and requests for human review. Test each route through LangBot before connecting Discord or LINE."
+---
+
+この記事は LangBot Blog から同期されています。 [Blog の原文を読む](https://langbot.app/en/blog/dify-langbot-support-triage).
+
+公開日:2026-09-16 · 著者:LangBot Team
+
+> この記事の日本語版はまだ公開されていないため、英語版を掲載しています。
+
+A single support prompt can work surprisingly well—until the same bot receives an expired invitation, an export question, and an angry refund request. Those messages need different responses and different limits.
+
+We built a three-route support assistant in Dify, then connected it through LangBot. GPT-6 Astra classifies the request and writes a reply for the selected route. The result is small enough to inspect: one classifier, three response nodes, and three outputs.
+
+
+
+*The actual support Chatflow, with separate account, product, and human-review routes.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Define what each route is allowed to do
+
+This example uses a fictional help-desk product called Northstar Desk. Its few product rules are included directly in the branch prompts, so you do not need a pre-existing knowledge base or another tutorial.
+
+| Route | Requests | Job |
+| --- | --- | --- |
+| Account | Login, invitations, workspace access | Explain the invitation recovery steps |
+| Product | CSV export, trial limits, basic setup | Answer from the small set of supplied product rules |
+| Human | Billing, refunds, enterprise pricing, complaints, unclear mixed requests | Explain that a human decision is needed and help the user prepare a concise request |
+
+The Human branch produces handoff guidance. It does not create a ticket, notify an employee, or approve a refund. That distinction belongs in the prompt: otherwise the assistant may casually imply it has performed an action that the workflow never implemented.
+
+## Build the classifier and three branches
+
+Create a **Chatflow** named `Astra Support Router`. Connect User Input to a **Question Classifier**, choose GPT-6 Astra, and set its query variable to `sys.query`.
+
+Add these categories:
+
+```text
+Account: login, invitation, password or workspace access
+Product: usage, ticket export, setup and trial limits
+Human: billing, refunds, enterprise pricing, angry complaints
+or unclear mixed questions
+```
+
+
+
+*The category descriptions define the routing boundary.*
+
+In the classifier's advanced instructions, tell it to choose Human when essential context is missing or the request needs authorization. Connect each branch to its own LLM node and Answer node.
+
+Use GPT-6 Astra in each LLM node and pass the user query through `sys.query`. Give the branches different system instructions:
+
+- **Account Reply:** invitations expire after 48 hours. Ask the workspace owner to resend from `Settings > Members`. Never request passwords. Ask one clarifying question if the problem is different.
+- **Product Reply:** owners export CSV tickets from `Settings > Data > Export`; export links last 7 days. The trial lasts 14 days and supports 5 teammates and 2 inboxes. Say when the supplied notes do not cover a question.
+- **Human Reply:** acknowledge the request and explain that a human must decide. Ask for a brief issue summary and a non-sensitive reference. Tell the user to contact their support team, without inventing an address, response-time promise, or completed action.
+
+All three prompts ask for concise replies in the user's language. Keeping the branch instructions separate makes a later change easier to review: adjusting billing guidance does not also change the login instructions.
+
+## Test the path, not only the wording
+
+We ran an expired-invitation question, a CSV-export question, and a refund complaint. The preview identified the selected output for each run:
+
+| Test | Observed output | Response |
+| --- | --- | --- |
+| Expired invitation | Account Output | Owner resend instructions and the 48-hour limit |
+| Export tickets as CSV | Product Output | Correct menu path and 7-day availability |
+| Refund complaint | Human Output | Human-review guidance without claiming a refund was issued |
+
+
+
+*The account test follows the Account branch.*
+
+
+
+*The export question follows the Product branch.*
+
+Expand the workflow process in Preview when a result looks odd. A plausible sentence can hide an incorrect route. That is why the output name is part of the test, rather than treating every fluent answer as a pass.
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Support Router`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Run it through LangBot
+
+The same expired-invitation question in LangBot returned the workspace-owner recovery steps. A later refund request in Chinese reached the human-review guidance. LangBot did not need its own copy of the classification rules.
+
+
+
+*An account-support response returned through LangBot.*
+
+Before putting this in a busy Discord server or a LINE support account, add ambiguous examples such as “I cannot log in and want a refund.” If those repeatedly land in Product, refine the category descriptions and classifier instructions.
+
+When you are ready for a real handoff, add your existing ticketing or notification API to the Human branch and handle its success and failure explicitly. The example gives you a visible place to add that action later.
+
+## Connect it to Discord and LINE
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*LINE requires its channel access token and channel secret.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](https://langbot.app/en/blog/dify-langbot-writing-assistant)
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-github-release-briefing)
diff --git a/docs/ja/articles/dify-langbot-writing-assistant.mdx b/docs/ja/articles/dify-langbot-writing-assistant.mdx
new file mode 100644
index 000000000..ae1664887
--- /dev/null
+++ b/docs/ja/articles/dify-langbot-writing-assistant.mdx
@@ -0,0 +1,179 @@
+---
+title: "A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot"
+description: "Build a basic Dify Chatbot for announcements, revisions and translations, then connect it to LangBot. Includes tested prompts, screenshots and Discord and Telegram setup guidance."
+---
+
+この記事は LangBot Blog から同期されています。 [Blog の原文を読む](https://langbot.app/en/blog/dify-langbot-writing-assistant).
+
+公開日:2026-09-16 · 著者:LangBot Team
+
+> この記事の日本語版はまだ公開されていないため、英語版を掲載しています。
+
+A community announcement usually starts as a few rough notes: a date, a channel, and something people need to bring. The first draft is easy. The annoying part is reopening another app for every “make it shorter” or “turn that into a Telegram reminder.”
+
+We built a small writing assistant that keeps those revisions in the conversation. Dify owns the prompt and chat history, GPT-6 Astra writes the response, and LangBot provides the messaging-platform entry point. Here is the complete setup, including the local deployment and the replies We tested.
+
+
+
+*A real response from Dify and Astra, shown in LangBot Debug Chat.*
+
+
+## Start with a working Dify and LangBot installation
+
+Start Docker Compose and Git, then follow the [Dify Docker Compose guide](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) and the [LangBot Docker guide](https://langbot.app/docs/en/deploy/langbot/docker/) to bring up both services. For Dify, clone the repository, enter `docker`, copy `.env.example` to `.env`, and start Compose. The LangBot guide includes the application image and plugin runtime configuration.
+
+The screenshots come from a local installation of **Dify 1.17.1 and LangBot v4.10.11**. We mapped the browser ports to `8088` and `5368`. Those are custom demo ports; the default installations normally use `80` and `5300`. With the defaults, open `http://localhost` and `http://localhost:5300`. You do not need to change ports just to match a screenshot. Create your own administrator accounts and select **English** in both interfaces.
+
+
+
+*The first-run screen of the local Dify deployment.*
+
+
+
+*The local LangBot installation before administrator setup.*
+
+The important requirement is that **the LangBot container can reach Dify**. Our two services share a Docker network, so the Runner uses `http://nginx/v1`, where `nginx` is Dify's service name. You can attach Dify's `nginx` service and LangBot to the same external network in their Compose configurations. For separate hosts, use a Dify HTTPS address reachable from LangBot and append `/v1`. Every `http://nginx/v1` example below refers to our shared network; replace it for your own deployment. Inside a container, `localhost` points back to that container.
+
+## Connect GPT-6 Astra to Dify
+
+Open **Integrations → Model Provider**, install the official **OpenAI-API-compatible** provider, and choose **Add Model**. We tested provider version 0.0.66 with an OpenAI-compatible gateway.
+
+| Field | Tutorial setting |
+| --- | --- |
+| Model Name / endpoint model name | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | Your provider's compatible endpoint, usually ending in `/v1` |
+| API Key | Your own provider key |
+| API Type | Chat Completions API |
+| Context size / maximum-token ceiling | 32768 / 4096 for this example |
+
+Those limits are conservative settings for this tutorial, not a statement of Astra's full specifications. Save the model and let Dify validate the connection. Replace the gateway shown in the screenshot with your own authorized endpoint. Model names and capabilities depend on your provider. This walkthrough retains the `gpt-6-astra` identifier used in the tested deployment.
+
+
+
+*The custom model form, captured before entering the API key.*
+
+
+## Build a basic Chatbot
+
+In Dify Studio, choose **Create → Create from Blank → More basic app types → Chatbot**. Name it `Astra Writing Assistant`. A basic Chatbot is enough here: we need a clear prompt and conversation history, without a branching workflow.
+
+
+
+*Dify keeps its basic Chatbot option under More basic app types.*
+
+
+
+*Give the assistant a name and a specific job.*
+
+Select GPT-6 Astra under **Configure model**, then add these instructions:
+
+```text
+You help a community manager turn rough notes into messages people can use.
+Reply in the language of the user's request.
+For an announcement, give: one clear headline, a short ready-to-send message,
+and one optional reminder.
+Preserve supplied dates, times, links, and requirements exactly.
+Never invent missing event details, discounts, results, or promises.
+If an essential detail is missing, ask one concise question.
+Use a natural, direct tone. Avoid hype and excessive emojis.
+Keep a typical reply under 180 words. When revising, use conversation context.
+```
+
+The most useful line is the one about preserving details. An announcement can survive plain wording. It cannot survive an invented RSVP link or the wrong time zone. We also kept the output short enough to be useful in a channel.
+
+
+
+*The prompt gives the assistant a narrow job and clear boundaries.*
+
+For the first test, we entered:
+
+> Write a Discord announcement: community demo day is September 22 at 18:00 UTC. Bring one bot demo. RSVP in #demo-day. Keep it friendly and under 100 words.
+
+The response preserved the date, time, and channel. We then asked, “Make it shorter, and remove the optional reminder.” The second reply kept the details from the first turn without asking us to paste them again.
+
+
+
+*The first English announcement generated in Dify.*
+
+
+
+*A follow-up instruction revises the existing announcement.*
+
+Publish the updated configuration with **Publish Update**. This step matters: an external caller should receive the version you just tested, rather than an older prompt still attached to the published app.
+
+
+
+*The published app exposes a backend API for LangBot.*
+
+
+## Put LangBot in front of the app
+
+Publish the saved version in Dify. Open **Access Point → Backend Service API → API Key** and create a key for this app.
+
+In LangBot, choose **Create Pipelines**, name the pipeline `Astra Writing Assistant`, and open **Configuration → AI**:
+
+| Field | Value |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | The Dify app key you just created |
+
+The Dify app key belongs here. The Astra provider key stays in Dify. Save the pipeline, then use **Debug Chat** to check the complete request and response path before adding a messaging platform.
+
+
+
+*The LangBot pipeline settings, captured before entering the app key.*
+
+
+## Test the same task through LangBot
+
+We asked LangBot for a two-sentence Telegram reminder. The reply came back through Dify with the supplied date and time intact. A later request switched the same conversation to Chinese, which also worked.
+
+For a community server, we would start with mention-based triggers in a single channel. Let members ask for a draft, shorten it, and adjust the tone before they decide to post it. That small loop is already useful for release announcements, event reminders, and repeated support replies.
+
+
+
+*The writing assistant answering through the LangBot pipeline.*
+
+
+## Connect it to Discord and Telegram
+
+Choose **Create Bots** in LangBot, select your adapter, and enter credentials from that platform. After creating the bot, select the pipeline you just built. Check the pipeline's trigger rules for direct messages, group messages, and mentions before inviting it into a channel.
+
+The measured path in this walkthrough is **LangBot → Dify → GPT-6 Astra → LangBot**. We inspected the real adapter forms below; we did not authorize a live Slack workspace, Discord server, Telegram bot, or LINE account for this demo.
+
+| Platform | Setup path |
+| --- | --- |
+| Slack | Create a Slack app, grant the required bot scopes, install it to the workspace, and enter the Bot Token and Signing Secret in LangBot. Configure event subscriptions with the public HTTPS callback from the bot setup, verify it, and invite the bot to the intended channel. |
+| Discord | Create an application and bot in the Developer Portal. Enter Client ID and bot Token, enable the required message intents, then invite the bot with the appropriate channel permissions. |
+| Telegram | Create a bot with BotFather and enter its token. Start with a direct message; for groups, invite the bot and configure privacy mode and LangBot's trigger rules for the messages it should receive. |
+| LINE | Create a Messaging API channel. Enter the Channel access token and Channel secret, configure the public HTTPS webhook, enable webhook delivery, and avoid conflicting automatic replies. |
+| Mattermost | Enter the server URL and a Bot Account access token, then add that account to the relevant teams and channels. The adapter uses REST and WebSocket APIs. |
+
+
+
+*The Discord adapter uses the application Client ID and bot token.*
+
+
+
+*The Telegram adapter uses a BotFather token and exposes reply options.*
+
+The catalog also includes Matrix, Lark, DingTalk, WeCom, WeChat-related adapters, QQ, KOOK, OneBot v11, Satori, HTTP Bot, and Page Bot. See the [platform-specific LangBot guides](https://langbot.app/docs/en/usage/platforms/readme) for the permissions and networking required by each adapter. In particular, the local-only deployment above needs a suitable public HTTPS endpoint for webhook-based integrations such as Slack and LINE.
+
+## Troubleshooting
+
+
+When something fails, check the path in order: Dify preview, the published app version, LangBot's `/v1` base URL and app type, then the platform's event delivery. If you change Dify service passwords, keep Redis and Celery credentials aligned, and do the same for Sandbox and its code-execution client.
+
+Our local proxy initially returned fake DNS addresses that Dify's SSRF proxy rejected. Correcting DNS fixed the plugin download without disabling network protections. For external API calls, inspect the run record after a retry rather than assuming that a transport error means the app configuration is wrong.
+
+Project sources: [LangBot](https://github.com/langbot-app/LangBot) and [Dify](https://github.com/langgenius/dify). The deployment and screenshots are from September 16, 2026; later versions may move some controls.
+
+
+## Further reading
+
+- [Route Support Questions in Discord and LINE with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-support-triage)
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](https://langbot.app/en/blog/dify-langbot-github-release-briefing)
diff --git a/docs/ja/articles/index.mdx b/docs/ja/articles/index.mdx
index 80401add0..c15f9e6f8 100644
--- a/docs/ja/articles/index.mdx
+++ b/docs/ja/articles/index.mdx
@@ -7,6 +7,7 @@ LangBot Blog の製品アップデート、技術解説、チュートリアル
## 製品アップデート
+- [LangBot 4.11:イベント駆動エージェントとプラグイン化された Runner](/ja/articles/langbot-v411-event-driven-agents) — 2026-10-15
- [新しい LangBot Cloud が公開—オープンソース版もマルチユーザー Workspace に対応](/ja/articles/langbot-cloud-multi-user-workspaces) — 2026-08-25
## エンジニアリング
@@ -17,6 +18,9 @@ LangBot Blog の製品アップデート、技術解説、チュートリアル
## チュートリアルと連携
+- [Turn GitHub Releases into Telegram Briefings with Dify and LangBot](/ja/articles/dify-langbot-github-release-briefing) — 2026-09-16
+- [Route Support Questions in Discord and LINE with Dify and LangBot](/ja/articles/dify-langbot-support-triage) — 2026-09-16
+- [A Dify Writing Assistant for Discord and Telegram with GPT-6 Astra and LangBot](/ja/articles/dify-langbot-writing-assistant) — 2026-09-16
- [LangTARS:Dify・n8n と連携するオープンソース PC 操作 Agent](/ja/articles/langtars-remote-pc-control-dify-n8n) — 2026-03-23
- [DeepSeek R1 を WeChat・Discord・Telegram に5分で接続する方法](/ja/articles/connect-deepseek-to-wechat) — 2026-02-21
- [AI Bot を Discord・Telegram・WeChat に5分でデプロイ](/ja/articles/deploy-ai-bot-in-5-minutes) — 2026-02-17
diff --git a/docs/ja/articles/langbot-v411-event-driven-agents.mdx b/docs/ja/articles/langbot-v411-event-driven-agents.mdx
new file mode 100644
index 000000000..0a8218f20
--- /dev/null
+++ b/docs/ja/articles/langbot-v411-event-driven-agents.mdx
@@ -0,0 +1,132 @@
+---
+title: "LangBot 4.11:イベント駆動エージェントとプラグイン化された Runner"
+description: "LangBot 4.11 の主要機能を実機スクリーンショットで紹介。イベントルーティング、AgentRunner のプラグイン化、Workspace Assistant、運用機能と移行手順を解説します。"
+---
+
+この記事は LangBot Blog から同期されています。 [Blog の原文を読む](https://langbot.app/ja/blog/langbot-v411-event-driven-agents).
+
+公開日:2026-10-15 · 著者:LangBot Team
+
+LangBot は、複数のメッセージングサービス、大規模言語モデル、ナレッジベース、プラグインを接続し、AI アシスタントの構築・デプロイ・管理を支援するオープンソースの AI チャットボット・エージェントプラットフォームです。
+
+チャットボットの基本は「メッセージを受け取り、返信する」ことです。ただ、実際のチームやコミュニティでは、会話以外にも作業のきっかけがあります。新しいメンバーの参加、回答へのリアクション、カードのボタンクリック。こうした操作も、次の処理につなげたい場面があります。
+
+**LangBot 4.11 では、これらのイベントを設定可能な入り口として扱い、エージェントに渡し、実行状況を追跡できるようになりました。** AgentRunner のプラグイン化と Workspace Assistant により、「何をきっかけに、どの実行方式で処理し、どう管理するか」を整理しやすくなっています。
+
+## 1. イベント駆動:次のメッセージを待つだけではない Bot へ
+
+Event-Based Agents(EBA)は、通常のメッセージに加え、参加・退出、メッセージの撤回、リアクション、カード操作などのプラットフォームイベントを処理対象にします。今回のリリースでは、13 のプラットフォームアダプターが EBA アーキテクチャに基づいて書き直されました。
+
+すべてのプラットフォームで同じイベントが使えるわけではありません。受信できるイベントは、プラットフォーム、アダプター、Bot に付与した権限によって異なります。共通化されたのは、受信したイベントを適切なプロセッサーへ渡す仕組みです。
+
+
+
+*4.11 正式版リリースノートに掲載されたイベントルーティングの例。*
+
+### まずは実現したいシナリオから
+
+イベントルーティングでは、新規メンバーへの歓迎や退出時の処理といった、よく使うシナリオからルールを作成し、担当するプロセッサーを選べます。
+
+たとえば、通常の質問はナレッジベースを使う Pipeline、新規参加者への案内は Agent、固定の業務ルールが必要なイベントはプラグインプロセッサーへ、と分担できます。これは構成例であり、そのまま動く設定ではありません。利用するイベントと権限を確認し、各ルートをテストしてから有効にしてください。
+
+プラットフォームとの接続と業務処理を分けられるため、歓迎の手順が変わっても Bot の接続部分まで作り直す必要がありません。質問への回答方針も、ほかの振る舞いと一つのプロンプトに詰め込まずに管理できます。
+
+### Pipeline・Agent・プラグインプロセッサーの使い分け
+
+
+
+*この記事用に起動した LangBot 4.11.0 のプロセッサー作成画面。*
+
+- **Pipeline**:メッセージイベントを決まった順序で処理します。AI による直接返信、ナレッジベースへの質問、既存のメッセージ処理に向いています。
+- **Agent**:イベントへの対応を自然言語で指示し、AI に判断と実行を任せるか、外部の Agent プラットフォームへ接続します。状況に応じた判断が必要な処理に適しています。
+- **プラグインプロセッサー**:プラグインが対応を宣言したイベントをコードで処理します。決定的なルールや専用の連携処理に向いています。
+
+すべてを Agent に置き換える必要はありません。明確なルールはコードへ、定型的な質疑応答は Pipeline へ、判断が必要な部分は Agent へ。役割を分けるほうが、運用時にも理解しやすくなります。
+
+### デバッグもイベントを中心に
+
+イベントデバッガーでは、フォームでイベントデータを編集し、実行ログと詳細を確認できます。実際のチャットサービスで試す前に、入力フィールドや処理結果を確認する用途に便利です。ただし、デバッグ実行だけで接続・権限・実際のコールバックまで確認できるわけではありません。
+
+関連するプラットフォーム改善には、Slack Socket Mode、DingTalk のカード操作、WeChat の入力中表示、OneBot WebSocket などがあります。対応範囲と設定方法は各プラットフォームのドキュメントを参照してください。
+
+## 2. AgentRunner のプラグイン化:実行方式を独立して更新
+
+これまで Core に組み込まれていた LocalAgent、Dify、Coze、n8n などの Runner は、拡張マーケットの公式プラグインへ移行しました。Codex、Claude Code、ACP などの Runner も追加されています。
+
+
+
+*実機から読み込んだ拡張マーケットの Official Runner セクション。掲載内容は Core と独立して更新されます。*
+
+利用者にとっての主な変化は、**必要な Runner を選んでインストールし、個別に更新できること**です。LocalAgent でモデル・ツール・知識検索を使う構成も、既存の Dify アプリや n8n ワークフローをチャットにつなぐ構成も、共通の Bot 接続基盤で扱えます。
+
+開発者は AgentRunner Protocol v1 に基づいて独自の Runner を実装できます。特定の実行基盤向けの処理を Core に直接組み込む必要がなくなり、拡張の境界が明確になります。
+
+
+
+*正式版リリースノートの Runner 一覧例。必要なアカウント、API、実行環境は Runner ごとに設定します。*
+
+プラグイン化は、すべての権限を無条件に渡すことではありません。Runner が呼び出せるのは、現在のコンテキストで許可されたツールとプラットフォーム API です。マーケットには認証バッジが表示され、Core は認証プラグインの署名を検証します。出所の確認と権限管理に加え、導入前に説明を読み、自分の環境での動作条件も確認してください。
+
+### 既存の Pipeline はどう移行する?
+
+移行機能は必要なプラグインをインストールし、元の設定をバックアップします。アップグレード後に Pipelines 画面で移行を実行し、Runner、モデル、ナレッジベース、ツールの設定を見直してください。
+
+**移行後の Pipeline は新しいセッションから開始します。** 旧式のカスタム Runner はプラグイン Runner として書き直す必要があります。オフライン環境では「データのみ移行」を選び、Runner プラグインを手動でインストールできます。詳しい対応関係は[設定移行マップ(中国語)](https://github.com/langbot-app/LangBot/blob/v4.11.0/docs/pipeline-migration-config-map.zh-CN.md)を参照してください。
+
+## 3. Workspace Assistant:確認してから反映する対話型の設定
+
+新しい Workspace Assistant は、Bot と会話するエンドユーザーではなく、LangBot を管理する人のための機能です。現在の Workspace にあるリソースを調べ、対話を通じて Pipeline やナレッジベースなどの作成・設定を支援します。
+
+
+
+*新しいインスタンスでの初期画面。利用には LangBot Account へのログイン、またはモデル設定が必要です。監視データのゼロ表示は、この新規 Workspace の実際の空状態です。*
+
+まずは「使えるモデル、ナレッジベース、Pipeline は?」といった読み取り専用の質問から始められます。そのうえで、ナレッジベースを使う質問応答アプリの作成を依頼します。単にフォームをチャットに置き換えるのではなく、現在あるリソースを確認したうえで設定案を出せる点が重要です。
+
+**書き込み操作は、確認を受けてから実行されます。** 対象リソース、パラメーター、変更範囲を見てから承認してください。設定項目を探す手間を減らしつつ、変更の判断は管理者に残す仕組みです。
+
+## 4. 新しい実行方式を支える運用機能
+
+### メッセージ数だけでなく、実行の流れを見る
+
+一つのイベントが Pipeline、Agent、プラグインのいずれかへ渡るようになると、問題の切り分けには処理全体の見通しが必要です。4.11 では統一実行ビュー、ツール呼び出しの追跡、処理中のタスクを示すリアルタイムパネルが追加・更新されました。
+
+調査するときは、イベントを受信したか、どのプロセッサーへ渡ったか、モデルやツールの呼び出しが完了したか、の順に確認すると整理しやすくなります。「返信がない」という現象だけで、接続とルーティングと実行の問題を一緒に扱わずに済みます。
+
+### アカウント保護と操作の追跡
+
+
+
+*実機のアカウント設定。撮影用アカウントでは Passkey と二要素認証をまだ有効にしていません。*
+
+WebAuthn による Passkey ログイン、TOTP による二要素認証に対応しました。また、変更内容を記録し、完全性を検証できる操作ログが追加されています。複数人で管理する Workspace では、ログイン保護と「誰が何を変えたか」の確認に役立ちます。Passkey はブラウザーのセキュアコンテキスト要件なども満たす必要があるため、適切な HTTPS 環境で設定してください。
+
+メディアは内容に基づいて重複を排除して保存され、同じメディアによる `langbot.db` の肥大化を抑えます。さらに、`lbctl` と API Key による管理・診断が使えるため、コマンドラインを既存の運用手順に組み込むこともできます。
+
+## 5. はじめての設定と、既存環境のアップグレード
+
+
+
+*実機の導入ウィザード。プラットフォームの選択から、Bot 設定、AI エンジンの設定へ進みます。*
+
+新規ユーザーはサイドバーのガイドとシナリオ別の Bot 作成ウィザードを使えます。最初にシンプルな質疑応答を確認し、その後にナレッジベース、ツール、イベントルートを足していくと、問題が起きたときに原因を追いやすくなります。
+
+既存環境では、次の順序で進めることをおすすめします。
+
+1. **バックアップを取得する。** `data/` または PostgreSQL データベースと、現在のデプロイ設定を保存します。最新の v4.10.x からの更新を推奨します。
+2. **Core と対応する Runtime を更新する。** 接続状態を確認してください。4.11.0 は SDK `0.7.11` に固定されているため、古い Runtime を混在させないようにします。
+3. **Pipeline を移行し、プラグインを更新する。** 移行後の Runner 設定と、新しいセッションになる点を確認します。
+4. **実際の利用経路をテストする。** 既存のメッセージ応答を先に確認し、その後でイベントルートや許可したツールを試します。
+5. **テレメトリーと設定変更を確認する。** 新しい実行テレメトリーは既定で有効ですが、メッセージ内容は含みません。このテレメトリーは `space.disable_telemetry: true` で無効化できます。設定には `media_cache`、`api.host`、`runner.admin_plugins` が追加され、`force_box_session_id_template` は削除されました。[データ収集ポリシー(英語)](https://langbot.app/docs/en/insight/data-collection-policy)とリリースノートを参照してください。
+
+## LangBot 4.11 を使い始める
+
+4.11 がつなぐのは、**タスクのきっかけ、処理する実行方式、そして管理・観察の方法**です。従来のメッセージ応答はそのまま活かしながら、コミュニティでのやり取りやイベント起点の処理を少しずつ追加できます。
+
+- [v4.11.0 と正式版リリースノート](https://github.com/langbot-app/LangBot/releases/tag/v4.11.0)
+- [デプロイ・利用ドキュメント](https://langbot.app/docs/ja/insight/guide)
+- [LangBot Cloud を利用する](https://cloud.langbot.app)
+
+開発、テスト、ドキュメント、フィードバックに協力してくださった皆さんに感謝します。本記事は主要な製品更新に絞って紹介しました。貢献者の一覧は正式版リリースノートをご覧ください。
+
+*画像について:イベントルーティングと Runner 一覧の二枚は v4.11.0 GitHub Release から引用しています。残る五枚は、この記事用に公式イメージ `rockchin/langbot:v4.11.0` を独立して起動し、実際の画面から撮影しました。各言語版で見比べやすいように UI は英語で統一しています。本番 Bot への接続は行っていません。*
diff --git a/docs/scripts/blog-articles-manifest.json b/docs/scripts/blog-articles-manifest.json
index db0513f9a..104861f15 100644
--- a/docs/scripts/blog-articles-manifest.json
+++ b/docs/scripts/blog-articles-manifest.json
@@ -22,6 +22,13 @@
"sourceLocale": "en",
"fallback": false
},
+ {
+ "locale": "en",
+ "slug": "dify-langbot-github-release-briefing",
+ "page": "en/articles/dify-langbot-github-release-briefing",
+ "sourceLocale": "en",
+ "fallback": false
+ },
{
"locale": "en",
"slug": "dify-langbot-rag-knowledge-base",
@@ -29,6 +36,20 @@
"sourceLocale": "en",
"fallback": false
},
+ {
+ "locale": "en",
+ "slug": "dify-langbot-support-triage",
+ "page": "en/articles/dify-langbot-support-triage",
+ "sourceLocale": "en",
+ "fallback": false
+ },
+ {
+ "locale": "en",
+ "slug": "dify-langbot-writing-assistant",
+ "page": "en/articles/dify-langbot-writing-assistant",
+ "sourceLocale": "en",
+ "fallback": false
+ },
{
"locale": "en",
"slug": "langbot-cloud-multi-user-workspaces",
@@ -50,6 +71,13 @@
"sourceLocale": "en",
"fallback": false
},
+ {
+ "locale": "en",
+ "slug": "langbot-v411-event-driven-agents",
+ "page": "en/articles/langbot-v411-event-driven-agents",
+ "sourceLocale": "en",
+ "fallback": false
+ },
{
"locale": "en",
"slug": "langbot-v490-rag-plugin-architecture",
@@ -106,6 +134,13 @@
"sourceLocale": "zh",
"fallback": false
},
+ {
+ "locale": "zh",
+ "slug": "dify-langbot-github-release-briefing",
+ "page": "zh/articles/dify-langbot-github-release-briefing",
+ "sourceLocale": "zh",
+ "fallback": false
+ },
{
"locale": "zh",
"slug": "dify-langbot-rag-knowledge-base",
@@ -113,6 +148,20 @@
"sourceLocale": "zh",
"fallback": false
},
+ {
+ "locale": "zh",
+ "slug": "dify-langbot-support-triage",
+ "page": "zh/articles/dify-langbot-support-triage",
+ "sourceLocale": "zh",
+ "fallback": false
+ },
+ {
+ "locale": "zh",
+ "slug": "dify-langbot-writing-assistant",
+ "page": "zh/articles/dify-langbot-writing-assistant",
+ "sourceLocale": "zh",
+ "fallback": false
+ },
{
"locale": "zh",
"slug": "langbot-cloud-multi-user-workspaces",
@@ -134,6 +183,13 @@
"sourceLocale": "zh",
"fallback": false
},
+ {
+ "locale": "zh",
+ "slug": "langbot-v411-event-driven-agents",
+ "page": "zh/articles/langbot-v411-event-driven-agents",
+ "sourceLocale": "zh",
+ "fallback": false
+ },
{
"locale": "zh",
"slug": "langbot-v490-rag-plugin-architecture",
@@ -190,6 +246,13 @@
"sourceLocale": "en",
"fallback": true
},
+ {
+ "locale": "ja",
+ "slug": "dify-langbot-github-release-briefing",
+ "page": "ja/articles/dify-langbot-github-release-briefing",
+ "sourceLocale": "en",
+ "fallback": true
+ },
{
"locale": "ja",
"slug": "dify-langbot-rag-knowledge-base",
@@ -197,6 +260,20 @@
"sourceLocale": "en",
"fallback": true
},
+ {
+ "locale": "ja",
+ "slug": "dify-langbot-support-triage",
+ "page": "ja/articles/dify-langbot-support-triage",
+ "sourceLocale": "en",
+ "fallback": true
+ },
+ {
+ "locale": "ja",
+ "slug": "dify-langbot-writing-assistant",
+ "page": "ja/articles/dify-langbot-writing-assistant",
+ "sourceLocale": "en",
+ "fallback": true
+ },
{
"locale": "ja",
"slug": "langbot-cloud-multi-user-workspaces",
@@ -218,6 +295,13 @@
"sourceLocale": "en",
"fallback": true
},
+ {
+ "locale": "ja",
+ "slug": "langbot-v411-event-driven-agents",
+ "page": "ja/articles/langbot-v411-event-driven-agents",
+ "sourceLocale": "ja",
+ "fallback": false
+ },
{
"locale": "ja",
"slug": "langbot-v490-rag-plugin-architecture",
@@ -445,6 +529,11 @@
"sha256": "1e2ee21c697ab9c2320094874889e2ac0bd2fc44efb529eafa4d895393ca5691",
"bytes": 161907
},
+ {
+ "path": "/images/articles/langbot-wechat-official-account-qr.png",
+ "sha256": "233aede79ed71fd721f1f77c839d726b9dd8ed667f5e951d994c19f962c6bff5",
+ "bytes": 14931
+ },
{
"path": "/images/articles/source/images/article-mcp/01-init-page.png",
"sha256": "b392e42d582c8c1723a2eb63596801b9d459df0465377246d9a4015edcae1ca0",
@@ -555,6 +644,231 @@
"sha256": "5ff0078633e30e8d8d8efa5abf686fbe13993b06cee383d2e7b9afec16639712",
"bytes": 11535
},
+ {
+ "path": "/images/articles/source/images/dify-langbot/00-dify-install.png",
+ "sha256": "395d9d5af74ef23be4e38d78addba382db83685831d6adf8e3a739675927f739",
+ "bytes": 48447
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/00-langbot-install.png",
+ "sha256": "e5ef7e954b4f27b50a5ea82cb551d33b4e6c288bd6948d146e6046c87b7a4717",
+ "bytes": 42532
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/007-langbot-dify-runner-config.png",
+ "sha256": "969e3095144b6f1671413e10e0d75304d8f9b593bb5ee38567f094ef3253773e",
+ "bytes": 98420
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/010-dify-create-basic-app.png",
+ "sha256": "34d5ad8474a7dede1d8a57c36951a3991076a104144e884646ecdd06cec08a56",
+ "bytes": 69242
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/011-dify-writing-app-create.png",
+ "sha256": "3296b7f25e30825817ed0034d078be68c369750b0f72959a2794d1aac8b2e800",
+ "bytes": 82300
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/012-dify-writing-instructions.png",
+ "sha256": "da31119dc383c45110d2ba7b122a1a4d0c5b348f7ab5c92660bce65a7387ed88",
+ "bytes": 91695
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/014-dify-astra-model-settings.png",
+ "sha256": "5c1c99021ce25747f0baf2d70619b2a550c9e72d51c0c867317f39ce16dc4dec",
+ "bytes": 82204
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/016-writing-english-first-reply.png",
+ "sha256": "7e2cbbc517ef47d20b577a7e37a5b04094dff7b321f117daed0f4461dfd218be",
+ "bytes": 120236
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/019-writing-english-context-revision.png",
+ "sha256": "28767ff8cf9c97c1a5f2cd02401cb7d3d8112197615c883857e074f0ca3b9036",
+ "bytes": 136430
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/022-writing-chinese-first-reply.png",
+ "sha256": "088bd387dd9855f506fb61b777a3b3bc11445b266963e27d7355447f6f366385",
+ "bytes": 108973
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/023-writing-access-point-api.png",
+ "sha256": "a431e9acda90e2685feeaa3a7353cf1b80c8ceb85ce1faf9da8f004ea9d22380",
+ "bytes": 63099
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/025-langbot-writing-english-roundtrip.png",
+ "sha256": "4ccbd575d3f544c050ce26cdda2f1996c12ed5875b024b0ec68fc2d5e8f0f8a2",
+ "bytes": 65801
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/026-langbot-writing-chinese-roundtrip.png",
+ "sha256": "b6de9ea94a9a9d9b8a57760425293ee3985e97f051efa2e9ff092cb64ae30ac8",
+ "bytes": 83068
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/039-triage-three-branch-overview.png",
+ "sha256": "dbef83edb2f04159e03a5bbeb5c004e09e250b022fdc6f845b045dd694e49773",
+ "bytes": 100101
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/040-triage-classifier-settings.png",
+ "sha256": "be1b06fe3754a5c7fcfd3f7c5f3cdd7192aac469ac94e08e66286b39c01672df",
+ "bytes": 121451
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/041-triage-account-path-result.png",
+ "sha256": "db7b607df345ca0c26a44ccacc0bf51853dcf485832f4ee49e2264e700f8fa47",
+ "bytes": 100428
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/043-triage-product-path-result.png",
+ "sha256": "5a61bc6b11cd58224dd517254a94ca3f8b66ef35bc6e5595ed43e900b90b3400",
+ "bytes": 99485
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/045-release-workflow-overview.png",
+ "sha256": "dc8cdf1c8b4c17b060be6945780a72ebbf913b3e773fa3f671b34a55a90065b2",
+ "bytes": 80979
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/046-release-http-request-settings.png",
+ "sha256": "c3f7259c4630a29d5570916d63780b0bd2425d84fc8664d899680eb7e77ea4af",
+ "bytes": 114686
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/047-release-code-extraction-node.png",
+ "sha256": "705f3f60f43b92457112c3d20666daf397346d5567f5cf532392c81194fd23e6",
+ "bytes": 100014
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/049-release-workflow-input.png",
+ "sha256": "4ff4d328a044b486cdcb207dcdb4f12c99bf8bd0e1fbce8577f7edc3cbcafa52",
+ "bytes": 111912
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/050-triage-human-review-chinese.png",
+ "sha256": "87c4e990534acb3dbf19d0033d17ed4bb772da14aee0713aa38781ccfccbf171",
+ "bytes": 114977
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/053-langbot-triage-dify-settings.png",
+ "sha256": "6211531960bd863619238b41ad48842e43d21323864159674a61db089df18c4a",
+ "bytes": 101283
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/054-release-summary-output-verified.png",
+ "sha256": "1f16403a2c8ee4c8adee05e36c5f094bc7d7316f45d5aac1c1275c75bfac6e84",
+ "bytes": 87480
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/055-langbot-triage-account-english.png",
+ "sha256": "476c5f68d231fe296737422fa3049b01b9c5064f830aa9743b067b94f2d99fee",
+ "bytes": 69092
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/056-release-english-live-briefing.png",
+ "sha256": "4ad4f72bbefc03cf746b31cd9fb84935f037ac657dd111586dbc02665633a4e3",
+ "bytes": 116517
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/059-platform-discord-fields.png",
+ "sha256": "ad46d06df6370e52e1baf2e611e5427f139efb7f3186b3bce705f8c270ad11fa",
+ "bytes": 59659
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/060-platform-telegram-fields.png",
+ "sha256": "e91de974825bb4f390bf1505c06fdba4f288bd1c35d8f7dfdd95f4a8d032fb91",
+ "bytes": 69438
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/061-platform-line-fields.png",
+ "sha256": "260b460f2be769b1888f8ed0ee92fcca095a55ea896b4cf658083af6cdfeaacc",
+ "bytes": 68738
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/062-platform-lark-fields.png",
+ "sha256": "2112231900edebf0ed28c2b9678aa95be0a338b01adf723028db8b150196773d",
+ "bytes": 89713
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/063-platform-dingtalk-fields.png",
+ "sha256": "0a9dc591fd65b3e5dd14932f288b38540543f162e742b5bd19c2d2b8337b49b2",
+ "bytes": 87438
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/064-platform-wecombot-fields.png",
+ "sha256": "441821ebe51b1863180a8c787b176f47609e9f7974fdeea85481c5936aa868f6",
+ "bytes": 86405
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/066-platform-onebot-v11-fields.png",
+ "sha256": "75988e0e76dd85f0b629c3769de55a963263a8f846bfdcea9035a60c628351e1",
+ "bytes": 74079
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/075-release-chinese-live-briefing.png",
+ "sha256": "0dc0d9e9fd3196dca8e96a1d61c86535c09cbc20599f9bbadaa59a9f53b47c8d",
+ "bytes": 119317
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/076-langbot-triage-human-chinese.png",
+ "sha256": "522a80eb09c6aec93301ae22507340b992e98c3a75fbd6f8850eb1f59afc3474",
+ "bytes": 94537
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/078-langbot-release-workflow-settings.png",
+ "sha256": "fcf07ab431b203da8742f8bc646262c01e1639e44103f35f95f528b4302b4690",
+ "bytes": 102591
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/079-langbot-release-english-roundtrip.png",
+ "sha256": "2db1b8dbac02a637aa844072c81db588e8cba50062c312ec64898e5ff660d1e6",
+ "bytes": 113719
+ },
+ {
+ "path": "/images/articles/source/images/dify-langbot/082-release-chinese-reply-detail.png",
+ "sha256": "f8a96b5411c7d84c05cde603de04f73ec2989e1748c22bb470457383ee3887d9",
+ "bytes": 39159
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/account-security.png",
+ "sha256": "df3fdf37a6482236cd231fd99c8e874241fa367fb25adc77af01ddcc6f10100f",
+ "bytes": 136563
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/eba-event-routing.png",
+ "sha256": "ea34a28e6c588a2a44e22fd7fb61bcd7f7699199af516dd25a865700ff837058",
+ "bytes": 85003
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/marketplace-live.png",
+ "sha256": "c93730f8a7d5d01d7c807ce9f00e708a44bd1e0f8618d37302088ae6c9d4036c",
+ "bytes": 155568
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/onboarding.png",
+ "sha256": "bb310d862fc44f095a20744e478a1991474bb8781880256a2d611e38fc106000",
+ "bytes": 104698
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/processor-types.png",
+ "sha256": "6d388a741a1cb12fe57a962b57b9094b3efa3249fe117c8397a8a7154781311b",
+ "bytes": 111538
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/runner-plugins-marketplace.png",
+ "sha256": "61cebdd8018aaf762be24ffcc5dd0b18cb392b33674e1b9ec7f4d4ef809568db",
+ "bytes": 140348
+ },
+ {
+ "path": "/images/articles/source/images/langbot-411/workspace-assistant.png",
+ "sha256": "ffb00eeb539ef28bd3db99db394eb799b052e5ff201fb2cbff4718a3985d24b2",
+ "bytes": 138965
+ },
{
"path": "/images/articles/source/images/plugin-system/architecture.png",
"sha256": "85028534d0a6246bd25f110189a6734f6176e1765fc64bf5eeb3248137e7292d",
@@ -589,6 +903,31 @@
"path": "/images/articles/source/images/sandbox-skills/skills.png",
"sha256": "6ad86d972c7438fbef898d9d339f3f17a30277bf1626e8a9dd11b0b46583c817",
"bytes": 362173
+ },
+ {
+ "path": "/images/articles/telegram-sandbox-qr.png",
+ "sha256": "8b4ead3dacb357fd28b650d2a6761b265effdeecc8e6a3f1f9feed10122da04b",
+ "bytes": 5583
+ },
+ {
+ "path": "/images/articles/telegram-sandbox.jpg",
+ "sha256": "0dab8f9c9c6846f8ac3b8848d118bca3528868254a7249d0d26772175eabae84",
+ "bytes": 349163
+ },
+ {
+ "path": "/images/articles/wecom-customer-service-qr.png",
+ "sha256": "3d2c2be11af63528a1b09871157ba58b4f3b64d7ddcbdf4420b99ecd41984c3b",
+ "bytes": 202053
+ },
+ {
+ "path": "/images/articles/wecom-customer-service.jpg",
+ "sha256": "2b94049e6265ffb1ac67cde661c596780d7fcfaeae46517ebd82f4dbfe3a1e19",
+ "bytes": 310163
+ },
+ {
+ "path": "/images/articles/wecom-knowledge-base-config.png",
+ "sha256": "0e4b12cec9bccf7c8a4958e9a53d312707ad818ac4376b707a20e8793af8f8be",
+ "bytes": 112097
}
]
}
diff --git a/docs/scripts/build-github-wiki.py b/docs/scripts/build-github-wiki.py
index 9304b92bf..2d35cc11b 100755
--- a/docs/scripts/build-github-wiki.py
+++ b/docs/scripts/build-github-wiki.py
@@ -73,7 +73,7 @@ def rewrite_url(source: PurePosixPath, target: str, *, image: bool = False) -> s
return target
if image:
path = parsed.path.lstrip("/") if parsed.path.startswith("/") else (source.parent / parsed.path).as_posix()
- return urlunsplit(("https", "raw.githubusercontent.com", f"/langbot-app/langbot-docs/main/{path}", parsed.query, parsed.fragment))
+ return urlunsplit(("https", "raw.githubusercontent.com", f"/langbot-app/LangBot/master/docs/{path}", parsed.query, parsed.fragment))
return resolve_doc_target(source, target) or target
@@ -235,7 +235,7 @@ def build(root: Path, output: Path) -> int:
home = [
"# LangBot Documentation",
"",
- "This GitHub Wiki is automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).",
+ "This GitHub Wiki is automatically synchronized from [LangBot/docs](https://github.com/langbot-app/LangBot/tree/master/docs).",
"",
]
for language, label in LANGUAGES.items():
@@ -246,7 +246,7 @@ def build(root: Path, output: Path) -> int:
(output / "Home.md").write_text("\n".join(home).rstrip() + "\n", encoding="utf-8")
(output / "_Sidebar.md").write_text(build_sidebar(root, titles), encoding="utf-8")
(output / "_Footer.md").write_text(
- "Automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).\n",
+ "Automatically synchronized from [LangBot/docs](https://github.com/langbot-app/LangBot/tree/master/docs).\n",
encoding="utf-8",
)
return len(documents)
diff --git a/docs/scripts/sync-blog-articles.mjs b/docs/scripts/sync-blog-articles.mjs
index 7a13deaba..76c1f7f29 100755
--- a/docs/scripts/sync-blog-articles.mjs
+++ b/docs/scripts/sync-blog-articles.mjs
@@ -9,6 +9,12 @@ import process from "node:process";
const ROOT = path.resolve(import.meta.dirname, "..");
const ARTICLE_LOCALES = ["en", "zh", "ja"];
+// These documentation-only articles are not owned by the Blog synchronizer.
+const LOCAL_ARTICLES = {
+ en: [],
+ zh: ["langbot-telegram-sandbox-agent", "langbot-wechat-official-account", "langbot-wecom-customer-service-knowledge-base"],
+ ja: [],
+};
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
const LANGUAGE_CONFIG = {
en: { mintlify: "en", tab: "Articles", introTitle: "LangBot Articles", introDescription: "Product updates, engineering deep dives, tutorials, integrations, and announcements from the LangBot Blog.", sourceLabel: "This article is synchronized from the LangBot Blog.", sourceAction: "Read the canonical version", fallbackLabel: "", overviewGroup: "Browse", groups: { updates: "Product Updates", engineering: "Engineering", tutorials: "Tutorials & Integrations", announcements: "Announcements" } },
@@ -225,6 +231,8 @@ async function renderArticle(sourceRoot, post, locale, fallback, assetRoot, imag
const config = LANGUAGE_CONFIG[locale];
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[post.slug] || post.data.title) : post.data.title;
let body = post.body;
+ // Root-relative Blog links belong to the landing site, not the embedded docs.
+ body = body.replace(/(\]\()\/(en|zh|ja)\/blog\//g, "$1https://langbot.app/$2/blog/");
const refs = imageReferences(body);
for (const ref of refs) if (!imageCache.has(ref.url)) imageCache.set(ref.url, await materializeImage(sourceRoot, ref.url, assetRoot));
if (post.data.cover?.image && !imageCache.has(post.data.cover.image)) imageCache.set(post.data.cover.image, await materializeImage(sourceRoot, post.data.cover.image, assetRoot));
@@ -254,6 +262,7 @@ function navigationTab(locale, posts) {
const groups = [{ group: config.overviewGroup, pages: [`${locale}/articles/index`] }];
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
const pages = posts.filter((post) => post.group === key).map((post) => articlePage(locale, post.slug));
+ if (key === "tutorials") pages.push(...LOCAL_ARTICLES[locale].map((slug) => articlePage(locale, slug)));
if (pages.length) groups.push({ group: config.groups[key], pages });
}
return { tab: config.tab, groups };
@@ -282,6 +291,13 @@ async function validateGenerated(manifest) {
}
}
}
+ for (const locale of ARTICLE_LOCALES) {
+ for (const slug of LOCAL_ARTICLES[locale]) {
+ const file = path.join(ROOT, locale, "articles", `${slug}.mdx`);
+ await readRegularFileWithin(path.join(ROOT, locale, "articles"), file, "local article", "utf8");
+ }
+ for (const image of await referencedImagesIn(path.join(ROOT, locale, "articles"))) referencedImages.add(image);
+ }
const mirroredMetadata = await imageMetadata(ROOT);
const mirroredImages = new Set(mirroredMetadata.map((image) => image.path));
for (const image of mirroredImages) if (!referencedImages.has(image)) failures.push(`unreferenced mirrored image: ${image}`);
@@ -340,6 +356,9 @@ async function main() {
if (!args.source) throw new Error("Pass --source /path/to/langbot-landing-page or set LANGBOT_LANDING_REPO");
if (!existsSync(path.join(args.source, "src/content/blog"))) throw new Error(`Not a LangBot landing repository: ${args.source}`);
const { byLocale, slugs } = await loadPosts(args.source);
+ for (const slug of Object.values(LOCAL_ARTICLES).flat()) {
+ if (slugs.includes(slug)) throw new Error(`Blog slug collides with a local article: ${slug}`);
+ }
const outputRoot = await mkdtemp(path.join(tmpdir(), "langbot-docs-article-sync-"));
const assetRoot = args.check ? outputRoot : ROOT;
const imageCache = new Map();
@@ -362,6 +381,23 @@ async function main() {
localePosts.set(locale, renderedPosts);
await writeFile(path.join(outputRoot, locale, "articles", "index.mdx"), renderIndex(locale, renderedPosts));
}
+ // Preserve local articles and their assets before replacing generated directories.
+ for (const locale of ARTICLE_LOCALES) {
+ for (const slug of LOCAL_ARTICLES[locale]) {
+ const source = path.join(ROOT, locale, "articles", `${slug}.mdx`);
+ const content = await readRegularFileWithin(path.join(ROOT, locale, "articles"), source, "local article", "utf8");
+ await writeFile(path.join(outputRoot, locale, "articles", `${slug}.mdx`), content);
+ if (args.check) {
+ for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) {
+ const image = path.join(ROOT, match[1].slice(1));
+ const bytes = await readRegularFileWithin(path.join(ROOT, "images/articles"), image, "local article image");
+ const target = path.join(outputRoot, match[1].slice(1));
+ await mkdir(path.dirname(target), { recursive: true });
+ await writeFile(target, bytes);
+ }
+ }
+ }
+ }
if (!args.check) {
const referencedImages = await referencedImagesIn(outputRoot);
for (const file of await listFiles(path.join(ROOT, "images/articles"))) {
diff --git a/docs/scripts/sync-landing-submodule.py b/docs/scripts/sync-landing-submodule.py
index 93b4fddc6..7feb83034 100644
--- a/docs/scripts/sync-landing-submodule.py
+++ b/docs/scripts/sync-landing-submodule.py
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
"""Advance only the landing docs gitlink, then sync its production fork."""
import argparse
+import base64
+import configparser
import json
import os
import urllib.error
import urllib.request
-SOURCE = "langbot-app/langbot-docs"
+SOURCE = "langbot-app/LangBot"
TARGET = "langbot-app/langbot-landing-page"
DEPLOY = "RockChinQ/LangBot-LandingPage-Vue"
@@ -25,14 +27,24 @@ def api(method, path, data=None):
return json.load(response)
-def head(call, repo):
- return call("GET", repo + "/git/ref/heads/main")["object"]["sha"]
+def head(call, repo, branch="main"):
+ return call("GET", repo + "/git/ref/heads/" + branch)["object"]["sha"]
def sync(call=api, dry_run=False):
for attempt in range(5):
- source = head(call, SOURCE) # Coalesce queued events onto current main.
+ source = head(call, SOURCE, "master")
parent = head(call, TARGET)
+ # Check the same target revision used to build the candidate tree. Never
+ # put a LangBot SHA into a gitlink still configured for the old repository.
+ modules = call("GET", TARGET + "/contents/.gitmodules?ref=" + parent)
+ config = configparser.ConfigParser(interpolation=None)
+ config.read_string(base64.b64decode(modules["content"]).decode("utf-8"))
+ section = 'submodule "docs"'
+ if (config.get(section, "path", fallback=None) != "docs"
+ or config.get(section, "url", fallback=None) != "https://github.com/langbot-app/LangBot.git"
+ or config.get(section, "branch", fallback=None) != "master"):
+ raise RuntimeError("Landing docs must point to LangBot/master before synchronization")
commit = call("GET", TARGET + "/git/commits/" + parent)
tree = call("GET", TARGET + "/git/trees/" + commit["tree"]["sha"])
entry = next(item for item in tree["tree"] if item["path"] == "docs")
@@ -52,7 +64,7 @@ def sync(call=api, dry_run=False):
"tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": source}],
})
candidate = call("POST", TARGET + "/git/commits", {
- "message": "docs: sync langbot-docs@" + source[:12],
+ "message": "docs: sync LangBot@" + source[:12],
"tree": updated["sha"], "parents": [parent],
"author": {"name": "github-actions[bot]", "email": "41898282+github-actions[bot]@users.noreply.github.com"},
})
diff --git a/docs/tests/agent-static-build.test.mjs b/docs/tests/agent-static-build.test.mjs
index a03bf5ee1..c477e5971 100644
--- a/docs/tests/agent-static-build.test.mjs
+++ b/docs/tests/agent-static-build.test.mjs
@@ -23,7 +23,8 @@ function links(node, result = []) {
}
test("every sitemap page has meaningful canonical Markdown and HTML discovery", async () => {
- assert.equal(urls.length, 509 + zhOnlyArticleRoutes.length);
+ const manifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8"));
+ assert.equal(urls.length, 473 + zhOnlyArticleRoutes.length + manifest.articles.length);
for (const url of urls) {
const route = relative(url);
const text = await read(`${route}.md`);
diff --git a/docs/tests/blog-articles.test.mjs b/docs/tests/blog-articles.test.mjs
index 9aa7ead4a..1b5f68cda 100644
--- a/docs/tests/blog-articles.test.mjs
+++ b/docs/tests/blog-articles.test.mjs
@@ -58,6 +58,7 @@ test("generated articles use only non-empty Wiki-local images", () => {
const file = path.join(root, `${article.page}.mdx`);
assert.ok(existsSync(file), `missing ${article.page}.mdx`);
const content = readFileSync(file, "utf8");
+ assert.doesNotMatch(content, /\]\(\/(?:en|zh|ja)\/blog\//, `${article.page} has a Blog link relative to the docs mount`);
const imageUrls = [...content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]);
for (const imageUrl of imageUrls) {
assert.match(imageUrl, /^\/images\/articles\//, `${article.page} hotlinks ${imageUrl}`);
@@ -72,3 +73,18 @@ test("generated articles use only non-empty Wiki-local images", () => {
}
}
});
+
+test("documentation-only articles and their images survive Blog synchronization", () => {
+ for (const pages of Object.values(extraArticlePages)) {
+ for (const page of pages) {
+ const content = readFileSync(path.join(root, `${page}.mdx`), "utf8");
+ assert.ok(content.length > 100, `${page} is empty`);
+ for (const [, url] of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) {
+ const bytes = readFileSync(path.join(root, url.slice(1)));
+ const image = manifest.images.find((item) => item.path === url);
+ assert.ok(image, `${page}: ${url} missing from image manifest`);
+ assert.equal(createHash("sha256").update(bytes).digest("hex"), image.sha256);
+ }
+ }
+ }
+});
diff --git a/docs/tests/fumapress-migration.test.mjs b/docs/tests/fumapress-migration.test.mjs
index e10cbb516..ca922eedc 100644
--- a/docs/tests/fumapress-migration.test.mjs
+++ b/docs/tests/fumapress-migration.test.mjs
@@ -14,6 +14,10 @@ import {
} from "../scripts/prepare-fumapress.mjs";
const root = path.resolve(import.meta.dirname, "..");
+const articleManifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8"));
+// The fixed documentation baseline excludes synchronized Blog pages.
+const expectedDocuments = 299 + articleManifest.articles.length;
+
test("Mintlify locales are mapped to public URL locales", () => {
assert.equal(mapMintlifyLocale("en"), "en");
@@ -170,8 +174,8 @@ test("MDX compatibility normalization removes internal .html route suffixes", ()
test("canonical MDX and nested OpenAPI sources are discovered", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const pages = await collectMdxDocuments(root);
- assert.equal(pages.length, 335);
- assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), { en: 106, zh: 123, ja: 106 });
+ assert.equal(pages.length, expectedDocuments);
+ assert.deepEqual(Object.fromEntries([...pages.reduce((acc, page) => acc.set(page.split("/", 1)[0], (acc.get(page.split("/", 1)[0]) ?? 0) + 1), new Map()).entries()]), Object.fromEntries(Object.entries({ en: 94, zh: 111, ja: 94 }).map(([locale, count]) => [locale, count + articleManifest.articles.filter((article) => article.locale === locale).length])));
assert.deepEqual(collectOpenApiSources(docs), [
{ locale: "en", source: "openapi/service-api-en.json", directory: "en/api-reference" },
{ locale: "zh", source: "openapi/service-api-zh.json", directory: "zh/api-reference" },
@@ -216,7 +220,7 @@ test("prebuild is deterministic and preserves local assets and SEO files", async
const first = await prepareFumapress({ root, outRoot: temp });
const second = await prepareFumapress({ root, outRoot: temp });
assert.deepEqual(first, second);
- assert.equal(first.documents, 335);
+ assert.equal(first.documents, expectedDocuments);
assert.equal(first.fallbackDefaults, 0);
assert.equal(first.localeOnlyDocuments, 17);
assert.equal(first.redirects, 103);
diff --git a/docs/tests/fumapress-static-build.test.mjs b/docs/tests/fumapress-static-build.test.mjs
index b1060887f..3d5dd7b1d 100644
--- a/docs/tests/fumapress-static-build.test.mjs
+++ b/docs/tests/fumapress-static-build.test.mjs
@@ -6,6 +6,10 @@ import { collectMdxDocuments } from "../scripts/prepare-fumapress.mjs";
import { zhOnlyArticleRoutes } from "./article-routes.mjs";
const root = path.resolve(import.meta.dirname, "..");
+const articleManifest = JSON.parse(await readFile(path.join(root, "scripts/blog-articles-manifest.json"), "utf8"));
+// The fixed documentation baseline excludes synchronized Blog pages.
+const expectedDocuments = 299 + articleManifest.articles.length;
+
const publicRoot = path.join(root, "dist/public");
test("the documentation keeps a neutral base with custom section and TOC treatments", async () => {
@@ -46,7 +50,7 @@ function staticHtmlForUrlPath(urlPath) {
test("all canonical localized documents have static HTML", async () => {
const documents = await collectMdxDocuments(root);
- assert.equal(documents.length, 335);
+ assert.equal(documents.length, expectedDocuments);
const missing = [];
for (const document of documents) {
try {
diff --git a/docs/tests/locale-static-build.test.mjs b/docs/tests/locale-static-build.test.mjs
index 91ebc66db..6ececb51b 100644
--- a/docs/tests/locale-static-build.test.mjs
+++ b/docs/tests/locale-static-build.test.mjs
@@ -35,6 +35,7 @@ test("every localized HTML payload includes the preference client provider", asy
}
}
for (const locale of ["en", "zh", "ja"]) await visit(path.join(root, locale));
- // 509 canonical content pages plus one framework 404 page per locale.
- assert.equal(count, 515);
+ // Every sitemap page plus one framework 404 page per locale.
+ const sitemap = await readFile(path.join(root, "sitemap.xml"), "utf8");
+ assert.equal(count, [...sitemap.matchAll(//g)].length + 3);
});
diff --git a/docs/tests/test_build_github_wiki.py b/docs/tests/test_build_github_wiki.py
index e8dd85593..130db8cca 100644
--- a/docs/tests/test_build_github_wiki.py
+++ b/docs/tests/test_build_github_wiki.py
@@ -36,7 +36,7 @@ description: "ignored"
self.assertIn("> **提示**", output)
self.assertIn("[模型文档](zh-usage-models-readme)", output)
self.assertIn(
- "https://raw.githubusercontent.com/langbot-app/langbot-docs/main/images/ui.png",
+ "https://raw.githubusercontent.com/langbot-app/LangBot/master/docs/images/ui.png",
output,
)
diff --git a/docs/tests/test_sync_landing_submodule.py b/docs/tests/test_sync_landing_submodule.py
index afcf72f34..0dd4ea16d 100644
--- a/docs/tests/test_sync_landing_submodule.py
+++ b/docs/tests/test_sync_landing_submodule.py
@@ -1,4 +1,5 @@
"""Offline API-contract tests; the workflow run verifies real GitHub writes."""
+import base64
import importlib.util
from pathlib import Path
import unittest
@@ -10,8 +11,9 @@ spec.loader.exec_module(sync)
class FakeAPI:
- def __init__(self, already=False, conflict=False, status="ahead"):
+ def __init__(self, already=False, conflict=False, status="ahead", migrated=True):
self.calls = []
+ self.migrated = migrated
self.pin = "new-docs" if already else "old-docs"
self.landing = "landing-parent"
self.deploy = "old-deploy"
@@ -20,7 +22,12 @@ class FakeAPI:
def __call__(self, method, path, data=None):
self.calls.append((method, path, data))
- if path.endswith("/git/ref/heads/main"):
+ if "/contents/.gitmodules?ref=" in path:
+ repo = "LangBot" if self.migrated else "langbot-docs"
+ branch = "master" if self.migrated else "main"
+ content = f'[submodule "docs"]\npath = docs\nurl = https://github.com/langbot-app/{repo}.git\nbranch = {branch}\n'
+ return {"content": base64.b64encode(content.encode()).decode()}
+ if "/git/ref/heads/" in path:
sha = "new-docs" if path.startswith(sync.SOURCE + "/") else self.deploy if path.startswith(sync.DEPLOY + "/") else self.landing
return {"object": {"sha": sha}}
if method == "GET" and "/git/commits/" in path:
@@ -83,3 +90,16 @@ class SyncTests(unittest.TestCase):
with self.assertRaises(RuntimeError):
sync.sync(api)
self.assertTrue(all(m == "GET" for m, p, d in api.calls))
+
+ def test_reads_master_and_does_not_contact_archived_repository(self):
+ api = FakeAPI()
+ sync.sync(api)
+ self.assertIn(("GET", "langbot-app/LangBot/git/ref/heads/master", None), api.calls)
+ self.assertFalse(any("langbot-app/langbot-docs/" in p for _, p, _ in api.calls))
+
+ def test_old_submodule_configuration_fails_before_any_write(self):
+ for already in [False, True]:
+ api = FakeAPI(already=already, migrated=False)
+ with self.assertRaisesRegex(RuntimeError, "must point to LangBot/master"):
+ sync.sync(api)
+ self.assertTrue(all(m == "GET" for m, _, _ in api.calls))
diff --git a/docs/zh/articles/dify-agent-discord-telegram-slack.mdx b/docs/zh/articles/dify-agent-discord-telegram-slack.mdx
index 558ba941e..eefc7b978 100644
--- a/docs/zh/articles/dify-agent-discord-telegram-slack.mdx
+++ b/docs/zh/articles/dify-agent-discord-telegram-slack.mdx
@@ -76,9 +76,9 @@ Please always maintain a friendly and professional attitude. If you need to use

-选择 **Dify Service API** 作为运行器:
+选择 **Dify Service API** 作为 Runner:
-
+
填写配置:
- **Base URL**: `https://api.dify.ai/v1`
diff --git a/docs/zh/articles/dify-langbot-github-release-briefing.mdx b/docs/zh/articles/dify-langbot-github-release-briefing.mdx
new file mode 100644
index 000000000..27934807e
--- /dev/null
+++ b/docs/zh/articles/dify-langbot-github-release-briefing.mdx
@@ -0,0 +1,224 @@
+---
+title: "在飞书里查询 GitHub 更新:Dify Workflow + GPT-6 Astra + LangBot 实战"
+description: "让 Dify 实时请求 GitHub API,代码节点提取版本信息,GPT-6 Astra 生成有来源的简报,再由 LangBot 返回。详解 Workflow 输入、HTTP、Code 和 summary 输出。"
+---
+
+本文同步自 LangBot Blog。 [阅读 Blog 原文](https://langbot.app/zh/blog/dify-langbot-github-release-briefing).
+
+发布于 2026-09-16 · 作者:LangBot Team
+
+项目更新一多,群里的问题也很固定:“最新版本是什么?”“这次有哪些变化值得看?”直接让模型凭记忆回答,碰上刚发布的版本就不靠谱。
+
+我们做了一个按需查询的 GitHub 简报助手:用户发消息后,Dify 真正请求 GitHub API,代码节点抽取发布信息,GPT-6 Astra 写成简短摘要,再由 LangBot 返回。下面用 LangBot 自己的公开仓库做例子;同样的方法也可以用在你关注的其他公开项目。
+
+
+
+*真实 API 数据生成的中文简报,保留版本、时间与 GitHub 原文链接。*
+
+
+## 先把 Dify 和 LangBot 跑起来
+
+先安装 Docker Compose 和 Git,再分别按 [Dify Docker Compose 部署文档](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) 与 [LangBot Docker 部署文档](https://langbot.app/docs/en/deploy/langbot/docker/) 启动两个服务。Dify 的基础步骤是克隆仓库、进入 `docker` 目录、将 `.env.example` 复制为 `.env`,再启动 Compose;LangBot 的文档提供完整的镜像和插件运行时配置。
+
+本文截图来自 **Dify 1.17.1、LangBot v4.10.11** 的本机环境。我们把浏览器端口分别映射为 `8088` 和 `5368`;这是演示环境的自定义映射,官方默认部署通常分别使用 `80` 和 `5300`。如果沿用官方默认值,就打开 `http://localhost` 和 `http://localhost:5300`,不用为了对齐截图而改端口。各自创建管理员账户,并把界面语言设为 **English**。
+
+
+
+*本地 Dify 的首次初始化页面。*
+
+
+
+*本地 LangBot 的首次初始化页面。*
+
+接入时最重要的是 **LangBot 容器能访问 Dify**。本次两个服务加入同一个 Docker 网络,因此 Runner 的 Base URL 使用 `http://nginx/v1`,其中 `nginx` 是 Dify 的服务名。你可以在两个 Compose 配置中为 Dify 的 `nginx` 和 LangBot 服务加入同一个外部网络;如果分别部署在不同主机,则填写 LangBot 能访问的 Dify HTTPS 地址并加上 `/v1`。下文表格里的 `http://nginx/v1` 都指本次共享网络环境,不能直接套到所有部署。容器内的 `localhost` 指向容器自己。
+
+## 在 Dify 中配置 GPT-6 Astra
+
+进入 **Integrations → Model Provider**,安装官方 **OpenAI-API-compatible** 插件,再点 **Add Model**。本次使用的插件版本是 0.0.66。
+
+| 字段 | 本次设置 |
+| --- | --- |
+| Model Name / model name for API endpoint | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | 你自己的 OpenAI 兼容服务地址,通常以 `/v1` 结尾 |
+| API Key | 你的模型服务密钥 |
+| API Type | Chat Completions API |
+| Model context size / Upper bound for max tokens | 本教程设为 32768 / 4096 |
+
+最后一行是这次演示的工作上限,不是对 Astra 完整规格的声明。保存时让 Dify 做连接验证,成功后再建应用。截图中的网关只是本次测试环境,实际使用时换成你自己的服务。模型参数和可用接口以所选服务为准。模型名称以你选用的服务实际提供的标识为准,本文保留本次实测的 `gpt-6-astra`。
+
+
+
+*添加自定义模型。API Key 在截图前保持为空,实际运行时已配置。*
+
+
+## 用 Workflow,把数据获取放在模型之前
+
+在 Dify 新建 **Workflow**,命名 `Astra Release Briefing`。这次不是 Chatflow,因为每条请求都可以独立查询最新发布记录。
+
+流程是:
+
+`User Request → HTTP Request → Code → LLM → End`
+
+
+
+*实际运行的 GitHub 简报工作流。*
+
+### 1. 用户输入字段要与 LangBot 对上
+
+在 Start / User Request 节点添加必填段落字段:
+
+```text
+langbot_user_message_text
+```
+
+LangBot 的 Dify Workflow Runner 会把当前消息文本放到这个字段中。不要随手改成 `question` 或 `prompt`,否则你在 Dify 手动测试能跑,接入 LangBot 时却可能缺少必填参数。
+
+
+
+*工作流运行表单中的输入变量名称。*
+
+### 2. HTTP 节点读取 GitHub
+
+方法选 GET,地址:
+
+```text
+https://api.github.com/repos/langbot-app/LangBot/releases/latest
+```
+
+请求头加上 `Accept: application/vnd.github+json` 和 `User-Agent: LangBot-Dify-Tutorial`。这是读取公开发布记录,本例不需要 GitHub Token。保留 SSL 验证,按实际网络设置连接和读取超时。
+
+
+
+*HTTP 节点直接查询 GitHub Releases API。*
+
+### 3. 代码节点缩小传给模型的数据
+
+把 HTTP 的 `body` 和 `status_code` 分别传给代码节点。核心逻辑如下,完整代码在 DSL 中:
+
+```python
+import json
+
+def main(body: str, status_code: int) -> dict:
+ if status_code != 200:
+ return {"release": json.dumps({"error": f"GitHub HTTP {status_code}"})}
+ data = json.loads(body)
+ return {"release": json.dumps({
+ "repository": "langbot-app/LangBot",
+ "tag": data.get("tag_name"),
+ "published_at": data.get("published_at"),
+ "url": data.get("html_url"),
+ "notes": (data.get("body") or "")[:14000],
+ }, ensure_ascii=False)}
+```
+
+保留标签、发布时间、链接和发布说明就够了。没有必要把作者头像、各种 API URL 等字段全部塞进模型上下文。这里的代码只做提取,没有修改 GitHub 数据。
+
+
+
+*代码节点把原始 JSON 转成精简的发布资料。*
+
+### 4. LLM 写摘要,End 返回 summary
+
+LLM 选择 GPT-6 Astra。提示词要求:用用户的语言回答,包含仓库、版本、发布时间、三个实用变化和原文链接;只使用 API 返回的资料,出错时说明查询失败。把用户输入和代码节点的 `release` 都放进 User 消息。
+
+在 End 节点,把 LLM 的 `text` 映射到名为 **`summary`** 的输出。这个名字同样是接入约定,不能只看着节点连线完整就忽略它。
+
+
+
+*End 节点把模型文本返回为 summary。*
+
+## 先核对数据,再看摘要
+
+我分别运行了英文和中文请求。两次都读取到 `v4.10.11`,发布时间为 2026-09-12 04:55:58 UTC,并附上对应的 GitHub Release 链接。摘要提到了知识库导入、监控统计和平台兼容性变化。
+
+
+
+*英文请求的真实运行结果。*
+
+
+
+*同一条工作流根据用户语言生成中文简报。*
+
+“最新”会随仓库发布变化,复现时不必追求和截图相同的版本号。应检查它是否与 API 当前返回的 `tag_name`、`published_at`、`html_url` 对得上。
+
+## 用 LangBot 接住消息入口
+
+在 Dify 点 **Publish**,发布当前版本。然后进入 **Access Point → Backend Service API → API Key**,创建这个应用自己的密钥。
+
+打开 LangBot,点 **Create Pipelines**,命名为 `Astra Release Briefing`。进入 **Configuration → AI**:
+
+| 字段 | 值 |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Workflow |
+| API Key | 刚创建的 Dify 应用密钥 |
+
+这里粘贴的是 **Dify 应用密钥**,不是前面配置 Astra 的模型密钥。一个负责调用应用,一个负责调用模型,别混用。保存后打开 **Debug Chat**,先验证整条调用链,再接平台。
+
+
+
+*LangBot 的 Dify Runner 配置;截图时尚未填入应用密钥。*
+
+
+## 工作流接进聊天之后
+
+我从 LangBot 发了英文查询,收到完整简报和来源链接。中文查询曾遇到一次上游 SSL 连接临时中断,重试后成功;这也提醒我们,截图里的成功运行不代表外部 API 永远可用。
+
+
+
+*LangBot 收到工作流的英文 summary 输出。*
+
+
+
+*中文重试成功后的简报内容。*
+
+如果接到飞书项目群,建议先设置提及触发。需要时问一次,就查一次,避免机器人反复刷屏。本文做的是按需查询;定时推送需要额外增加调度与发送逻辑。
+
+扩展到其他仓库时,可以先复制应用并替换固定 API URL。等规则稳定后再加入仓库参数、输入校验和限流,不要让任意用户输入直接变成不受限制的请求地址。
+
+## 接到飞书、钉钉、企业微信,以及其他消息平台
+
+在 LangBot 点 **Create Bots**,选择适配器,填写平台提供的凭据。创建后把机器人绑定到刚才的流水线;检查 Trigger 中的私聊、群聊和提及规则,再按平台要求发布或邀请机器人。
+
+本次实测覆盖 **LangBot → Dify → GPT-6 Astra → LangBot 回复**。以下是实际界面核对过的接入方式,平台账户与群聊没有在本次演示中授权连接。
+
+| 入口 | 配置方式 |
+| --- | --- |
+| 飞书 / Lark | 选 Lark;可扫码创建应用,也可手填 App ID、App Secret、与平台一致的 Bot Name。中国平台域名选 Feishu,默认使用长连接;开发者后台配置消息权限、事件并发布应用。 |
+| 钉钉 | 选 DingTalk;扫码或填写 Client ID、Client Secret。Robot Code 需从开发者后台复制;按文档配置 Stream 接收消息、机器人名称和需要的卡片模板。 |
+| 企业微信智能机器人 | 选 WeComBot;长连接用 BotId、Secret,填写机器人名称。Webhook 模式另配 Corpid、Token、EncodingAESKey。两套模式不要混填。 |
+| 企业微信内部应用 / 微信客服 | 分别选 WeCom 或 WeComCustomerService,按各自后台填写企业与应用凭据、Token、EncodingAESKey,完成 HTTPS 回调和可信 IP 配置。内部应用不能直接当群机器人使用。 |
+| 微信个人会话 | 选 OpenClaw WeChat,通过界面的二维码授权登录,按适配器说明接入。 |
+| 微信公众号 | 选 Official Account,填写 App ID、App Secret、Token、EncodingAESKey,配置公网回调与 IP 白名单。根据接口限制选择回复模式;耗时较长时可能需要用户再发一条消息取回结果。 |
+| QQ:OneBot v11 | 运行兼容 OneBot v11 的协议端,配置反向 WebSocket 到 LangBot 的监听端口,两侧 Access Token 保持一致;演示环境的 Compose 把主机 2288 映射到容器 2280。 |
+| QQ 官方机器人 | 选 QQ Official API;可扫码绑定或填写 App ID、Secret。当前界面说明 Token 可留空;接收模式、沙箱范围、发布审核以 QQ 官方及适配器文档为准。 |
+
+
+
+*钉钉适配器的凭据、Robot Code 与卡片配置。*
+
+
+
+*企业微信智能机器人的长连接与 Webhook 配置项。*
+
+LangBot 的适配器列表还包括 Discord、Slack、Telegram、LINE、Mattermost、Matrix、KOOK、Satori、HTTP Bot、Page Bot 和 WeChatPad。每种适配器需要的权限、回调和协议不同,不是填一个通用 Token 就全部接通。完整入口见 [LangBot 平台文档](https://langbot.app/docs/zh/usage/platforms/readme)。
+
+如果只是想先模拟 QQ 消息,可以使用 matcha 之类的 OneBot 测试端;本文展示的对话来自 LangBot 自带 Debug Chat,没有把模拟对话当作真实群消息。
+
+## 几个容易卡住的地方
+
+
+遇到问题先按链路定位:Dify 预览能否回复、应用是否已 Publish、LangBot 的 Base URL 是否包含 `/v1`、应用类型是否正确,最后才看平台事件是否到达。如果改过 Dify 服务密码,Redis 与 Celery、Sandbox 与代码执行客户端的配置必须成对修改。
+
+本机部署还遇到过代理软件返回虚拟 DNS 地址,导致 Dify 的 SSRF 代理拒绝插件下载。修好 DNS 后恢复,没必要关掉证书校验或内网访问保护。一次外部 API 的临时失败也不能当作模型或平台已经接通的证明,重试后仍应检查运行记录。
+
+项目入口:[LangBot](https://github.com/langbot-app/LangBot) 和 [Dify](https://github.com/langgenius/dify)。截图和测试日期为 2026 年 9 月 16 日,后续版本的菜单名称可能会变化。
+
+
+## 继续阅读
+
+- [把群公告交给 Dify:用 GPT-6 Astra 和 LangBot 搭建 QQ、飞书写作助手](https://langbot.app/zh/blog/dify-langbot-writing-assistant)
+- [让客服问题走对分支:Dify + LangBot 钉钉、企业微信机器人实战](https://langbot.app/zh/blog/dify-langbot-support-triage)
diff --git a/docs/zh/articles/dify-langbot-support-triage.mdx b/docs/zh/articles/dify-langbot-support-triage.mdx
new file mode 100644
index 000000000..70f3da0cf
--- /dev/null
+++ b/docs/zh/articles/dify-langbot-support-triage.mdx
@@ -0,0 +1,197 @@
+---
+title: "让客服问题走对分支:Dify + LangBot 钉钉、企业微信机器人实战"
+description: "用 GPT-6 Astra 和 Dify Question Classifier 区分账户、产品与人工复核请求,再接入 LangBot。三条分支逐一测试,说明回复规则与真实交接的边界。"
+---
+
+本文同步自 LangBot Blog。 [阅读 Blog 原文](https://langbot.app/zh/blog/dify-langbot-support-triage).
+
+发布于 2026-09-16 · 作者:LangBot Team
+
+把所有客服问题塞给同一段提示词,刚开始挺省事。后来你会发现,登录失败、功能咨询、退款投诉混在一起,机器人经常用同一种口气回复,还容易在不该承诺的地方说得太满。
+
+这次我们把流程拆成三条:账户问题给排查步骤,产品问题给使用说明,需要授权的事情提示人工复核。Dify 的 Question Classifier 决定走哪条路,GPT-6 Astra 生成对应回复,LangBot 把同一个应用接到钉钉、企业微信或 QQ。
+
+
+
+*实际部署的三分支客服 Chatflow。*
+
+
+## 先把 Dify 和 LangBot 跑起来
+
+先安装 Docker Compose 和 Git,再分别按 [Dify Docker Compose 部署文档](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) 与 [LangBot Docker 部署文档](https://langbot.app/docs/en/deploy/langbot/docker/) 启动两个服务。Dify 的基础步骤是克隆仓库、进入 `docker` 目录、将 `.env.example` 复制为 `.env`,再启动 Compose;LangBot 的文档提供完整的镜像和插件运行时配置。
+
+本文截图来自 **Dify 1.17.1、LangBot v4.10.11** 的本机环境。我们把浏览器端口分别映射为 `8088` 和 `5368`;这是演示环境的自定义映射,官方默认部署通常分别使用 `80` 和 `5300`。如果沿用官方默认值,就打开 `http://localhost` 和 `http://localhost:5300`,不用为了对齐截图而改端口。各自创建管理员账户,并把界面语言设为 **English**。
+
+
+
+*本地 Dify 的首次初始化页面。*
+
+
+
+*本地 LangBot 的首次初始化页面。*
+
+接入时最重要的是 **LangBot 容器能访问 Dify**。本次两个服务加入同一个 Docker 网络,因此 Runner 的 Base URL 使用 `http://nginx/v1`,其中 `nginx` 是 Dify 的服务名。你可以在两个 Compose 配置中为 Dify 的 `nginx` 和 LangBot 服务加入同一个外部网络;如果分别部署在不同主机,则填写 LangBot 能访问的 Dify HTTPS 地址并加上 `/v1`。下文表格里的 `http://nginx/v1` 都指本次共享网络环境,不能直接套到所有部署。容器内的 `localhost` 指向容器自己。
+
+## 在 Dify 中配置 GPT-6 Astra
+
+进入 **Integrations → Model Provider**,安装官方 **OpenAI-API-compatible** 插件,再点 **Add Model**。本次使用的插件版本是 0.0.66。
+
+| 字段 | 本次设置 |
+| --- | --- |
+| Model Name / model name for API endpoint | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | 你自己的 OpenAI 兼容服务地址,通常以 `/v1` 结尾 |
+| API Key | 你的模型服务密钥 |
+| API Type | Chat Completions API |
+| Model context size / Upper bound for max tokens | 本教程设为 32768 / 4096 |
+
+最后一行是这次演示的工作上限,不是对 Astra 完整规格的声明。保存时让 Dify 做连接验证,成功后再建应用。截图中的网关只是本次测试环境,实际使用时换成你自己的服务。模型参数和可用接口以所选服务为准。模型名称以你选用的服务实际提供的标识为准,本文保留本次实测的 `gpt-6-astra`。
+
+
+
+*添加自定义模型。API Key 在截图前保持为空,实际运行时已配置。*
+
+
+## 先定义三类问题,再画流程
+
+演示产品是一个虚构的帮助台产品 Northstar Desk。本文用到的规则直接写在三个分支中,不依赖另一篇教程或预先存在的知识库。
+
+| 分支 | 适合的问题 | 回复任务 |
+| --- | --- | --- |
+| Account | 登录、邀请链接、工作区访问 | 说明邀请有效 48 小时,指导所有者重新发送邀请 |
+| Product | 导出工单、试用限制、基础设置 | 回答已给定的产品规则和操作路径 |
+| Human | 退款、收费、企业报价、投诉、混合不清的问题 | 说明需要人工决定,收集简短说明并给出联系支持团队的建议 |
+
+第三条分支的作用是生成交接提示。本例没有连接工单系统,也不会真的通知客服,所以回复不能写“已转交”“退款已批准”。以后接入工单 API 时,再把真实执行结果接到这条分支。
+
+## 在 Dify 创建分流 Chatflow
+
+新建 **Chatflow**,名称为 `Astra Support Router`。从 User Input 连到 **Question Classifier**,模型选择 GPT-6 Astra,Query Variable 指向 `sys.query`。
+
+添加三类,说明尽量写清楚范围:
+
+```text
+Account: login, invitation, password or workspace access
+Product: usage, ticket export, setup and trial limits
+Human: billing, refunds, enterprise pricing, angry complaints
+or unclear mixed questions
+```
+
+
+
+*分类节点里的类别说明会直接影响路由结果。*
+
+在 Advanced Setting 中补充一句:缺少关键信息、需要授权或者混合问题不明确时,选择 Human。分类器的三条出口分别连接一个 LLM 节点,再各自接 Answer。
+
+三个 LLM 都选 GPT-6 Astra,用户消息引用 `sys.query`,但系统规则分别写:
+
+- **Account Reply**:邀请链接有效 48 小时;请工作区所有者到 `Settings > Members` 重新发送。不要索要密码。其他账户问题先问一个澄清问题。
+- **Product Reply**:所有者从 `Settings > Data > Export` 导出 CSV,链接保留 7 天;试用 14 天、5 名成员、2 个收件箱。规则没覆盖的内容直接说明。
+- **Human Reply**:理解诉求,解释需要人工决定,请对方提供简短问题描述和非敏感参考信息,联系其支持团队。不要编造客服地址、承诺处理时效或声称已经执行。
+
+共用规则只有几条:跟随用户语言、简短、不要重复冗长开场。把业务规则分在对应节点里,以后修改退款交接话术时,就不用担心顺便改坏登录帮助。
+
+## 每个分支都要实际跑一次
+
+我们在 Preview 中做了三组测试:
+
+| 输入 | 实际路径 | 结果 |
+| --- | --- | --- |
+| My invitation link expired... | Account Output | 建议让所有者重新发邀请,说明 48 小时有效期 |
+| How do I export tickets as a CSV? | Product Output | 返回导出路径和 7 天有效期 |
+| 我对收费很不满意,要求退款,马上处理。 | Human Output | 说明需人工审核,不声称退款已经执行 |
+
+
+
+*账户问题走 Account 分支。*
+
+
+
+*产品问题走 Product 分支。*
+
+
+
+*退款诉求走 Human 分支,输出人工复核建议。*
+
+注意看预览里的 **Workflow Process succeeded** 和具体的 Output 名称。只读最后一段回答,容易错过“答得似乎没错,但实际上走错了分支”的问题。
+
+## 用 LangBot 接住消息入口
+
+在 Dify 点 **Publish**,发布当前版本。然后进入 **Access Point → Backend Service API → API Key**,创建这个应用自己的密钥。
+
+打开 LangBot,点 **Create Pipelines**,命名为 `Astra Support Router`。进入 **Configuration → AI**:
+
+| 字段 | 值 |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | 刚创建的 Dify 应用密钥 |
+
+这里粘贴的是 **Dify 应用密钥**,不是前面配置 Astra 的模型密钥。一个负责调用应用,一个负责调用模型,别混用。保存后打开 **Debug Chat**,先验证整条调用链,再接平台。
+
+
+
+*LangBot 的 Dify Runner 配置;截图时尚未填入应用密钥。*
+
+
+## 在 LangBot 中验证分流仍然有效
+
+我们先在 LangBot 问邀请链接失效,得到 `Settings > Members` 的建议;再用中文提出退款诉求,得到人工复核提示。Dify 的分类逻辑不用在 LangBot 里再写一遍。
+
+
+
+*LangBot 中的账户问题回复。*
+
+
+
+*同一应用处理中文退款诉求,保持人工审核边界。*
+
+上线前可以再加一小组混合问题:“无法登录,而且我要退款”“不知道该找谁”。如果这些问题总被分进 Product,先调整类别描述和分类指令,再考虑换模型。
+
+这个方案最适合把重复支持工作先分清楚。接下来要做真实交接,就在 Human 分支调用已有工单或通知服务,并把成功、失败结果分别呈现给用户。不要把“生成一句交接话术”当成已经完成交接。
+
+## 接到钉钉、企业微信、QQ,以及其他消息平台
+
+在 LangBot 点 **Create Bots**,选择适配器,填写平台提供的凭据。创建后把机器人绑定到刚才的流水线;检查 Trigger 中的私聊、群聊和提及规则,再按平台要求发布或邀请机器人。
+
+本次实测覆盖 **LangBot → Dify → GPT-6 Astra → LangBot 回复**。以下是实际界面核对过的接入方式,平台账户与群聊没有在本次演示中授权连接。
+
+| 入口 | 配置方式 |
+| --- | --- |
+| 飞书 / Lark | 选 Lark;可扫码创建应用,也可手填 App ID、App Secret、与平台一致的 Bot Name。中国平台域名选 Feishu,默认使用长连接;开发者后台配置消息权限、事件并发布应用。 |
+| 钉钉 | 选 DingTalk;扫码或填写 Client ID、Client Secret。Robot Code 需从开发者后台复制;按文档配置 Stream 接收消息、机器人名称和需要的卡片模板。 |
+| 企业微信智能机器人 | 选 WeComBot;长连接用 BotId、Secret,填写机器人名称。Webhook 模式另配 Corpid、Token、EncodingAESKey。两套模式不要混填。 |
+| 企业微信内部应用 / 微信客服 | 分别选 WeCom 或 WeComCustomerService,按各自后台填写企业与应用凭据、Token、EncodingAESKey,完成 HTTPS 回调和可信 IP 配置。内部应用不能直接当群机器人使用。 |
+| 微信个人会话 | 选 OpenClaw WeChat,通过界面的二维码授权登录,按适配器说明接入。 |
+| 微信公众号 | 选 Official Account,填写 App ID、App Secret、Token、EncodingAESKey,配置公网回调与 IP 白名单。根据接口限制选择回复模式;耗时较长时可能需要用户再发一条消息取回结果。 |
+| QQ:OneBot v11 | 运行兼容 OneBot v11 的协议端,配置反向 WebSocket 到 LangBot 的监听端口,两侧 Access Token 保持一致;演示环境的 Compose 把主机 2288 映射到容器 2280。 |
+| QQ 官方机器人 | 选 QQ Official API;可扫码绑定或填写 App ID、Secret。当前界面说明 Token 可留空;接收模式、沙箱范围、发布审核以 QQ 官方及适配器文档为准。 |
+
+
+
+*钉钉适配器的凭据、Robot Code 与卡片配置。*
+
+
+
+*QQ 的 OneBot v11 方案需要反向 WebSocket 监听地址和端口。*
+
+LangBot 的适配器列表还包括 Discord、Slack、Telegram、LINE、Mattermost、Matrix、KOOK、Satori、HTTP Bot、Page Bot 和 WeChatPad。每种适配器需要的权限、回调和协议不同,不是填一个通用 Token 就全部接通。完整入口见 [LangBot 平台文档](https://langbot.app/docs/zh/usage/platforms/readme)。
+
+如果只是想先模拟 QQ 消息,可以使用 matcha 之类的 OneBot 测试端;本文展示的对话来自 LangBot 自带 Debug Chat,没有把模拟对话当作真实群消息。
+
+## 几个容易卡住的地方
+
+
+遇到问题先按链路定位:Dify 预览能否回复、应用是否已 Publish、LangBot 的 Base URL 是否包含 `/v1`、应用类型是否正确,最后才看平台事件是否到达。如果改过 Dify 服务密码,Redis 与 Celery、Sandbox 与代码执行客户端的配置必须成对修改。
+
+本机部署还遇到过代理软件返回虚拟 DNS 地址,导致 Dify 的 SSRF 代理拒绝插件下载。修好 DNS 后恢复,没必要关掉证书校验或内网访问保护。一次外部 API 的临时失败也不能当作模型或平台已经接通的证明,重试后仍应检查运行记录。
+
+项目入口:[LangBot](https://github.com/langbot-app/LangBot) 和 [Dify](https://github.com/langgenius/dify)。截图和测试日期为 2026 年 9 月 16 日,后续版本的菜单名称可能会变化。
+
+
+## 继续阅读
+
+- [把群公告交给 Dify:用 GPT-6 Astra 和 LangBot 搭建 QQ、飞书写作助手](https://langbot.app/zh/blog/dify-langbot-writing-assistant)
+- [在飞书里查询 GitHub 更新:Dify Workflow + GPT-6 Astra + LangBot 实战](https://langbot.app/zh/blog/dify-langbot-github-release-briefing)
diff --git a/docs/zh/articles/dify-langbot-writing-assistant.mdx b/docs/zh/articles/dify-langbot-writing-assistant.mdx
new file mode 100644
index 000000000..868bfa6c0
--- /dev/null
+++ b/docs/zh/articles/dify-langbot-writing-assistant.mdx
@@ -0,0 +1,183 @@
+---
+title: "把群公告交给 Dify:用 GPT-6 Astra 和 LangBot 搭建 QQ、飞书写作助手"
+description: "从基础 Chatbot 开始,把零散信息变成群公告,再通过 LangBot 连续追问、压缩和翻译。含真实部署截图、提示词和 QQ、飞书接入说明。"
+---
+
+本文同步自 LangBot Blog。 [阅读 Blog 原文](https://langbot.app/zh/blog/dify-langbot-writing-assistant).
+
+发布于 2026-09-16 · 作者:LangBot Team
+
+群里要发一条活动通知,手头往往只有几句零散信息:时间、地点、报名方式,还得再花几分钟调整口吻。真正烦的不是写第一版,而是“短一点”“别那么正式”“换成企微群通知”这几轮来回。
+
+这次我们把这件事做成了一个聊天助手:Dify 管提示词和会话,GPT-6 Astra 负责改写,LangBot 负责把入口放到平时就在用的消息平台里。下面从部署开始,做一个可以继续追问和改稿的应用。
+
+
+
+*实际链路:在 LangBot 里把英文提醒改成中文飞书群提醒。*
+
+
+## 先把 Dify 和 LangBot 跑起来
+
+先安装 Docker Compose 和 Git,再分别按 [Dify Docker Compose 部署文档](https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose) 与 [LangBot Docker 部署文档](https://langbot.app/docs/en/deploy/langbot/docker/) 启动两个服务。Dify 的基础步骤是克隆仓库、进入 `docker` 目录、将 `.env.example` 复制为 `.env`,再启动 Compose;LangBot 的文档提供完整的镜像和插件运行时配置。
+
+本文截图来自 **Dify 1.17.1、LangBot v4.10.11** 的本机环境。我们把浏览器端口分别映射为 `8088` 和 `5368`;这是演示环境的自定义映射,官方默认部署通常分别使用 `80` 和 `5300`。如果沿用官方默认值,就打开 `http://localhost` 和 `http://localhost:5300`,不用为了对齐截图而改端口。各自创建管理员账户,并把界面语言设为 **English**。
+
+
+
+*本地 Dify 的首次初始化页面。*
+
+
+
+*本地 LangBot 的首次初始化页面。*
+
+接入时最重要的是 **LangBot 容器能访问 Dify**。本次两个服务加入同一个 Docker 网络,因此 Runner 的 Base URL 使用 `http://nginx/v1`,其中 `nginx` 是 Dify 的服务名。你可以在两个 Compose 配置中为 Dify 的 `nginx` 和 LangBot 服务加入同一个外部网络;如果分别部署在不同主机,则填写 LangBot 能访问的 Dify HTTPS 地址并加上 `/v1`。下文表格里的 `http://nginx/v1` 都指本次共享网络环境,不能直接套到所有部署。容器内的 `localhost` 指向容器自己。
+
+## 在 Dify 中配置 GPT-6 Astra
+
+进入 **Integrations → Model Provider**,安装官方 **OpenAI-API-compatible** 插件,再点 **Add Model**。本次使用的插件版本是 0.0.66。
+
+| 字段 | 本次设置 |
+| --- | --- |
+| Model Name / model name for API endpoint | `gpt-6-astra` |
+| Model Type / Completion mode | LLM / Chat |
+| Model display name | GPT-6 Astra |
+| API Base URL | 你自己的 OpenAI 兼容服务地址,通常以 `/v1` 结尾 |
+| API Key | 你的模型服务密钥 |
+| API Type | Chat Completions API |
+| Model context size / Upper bound for max tokens | 本教程设为 32768 / 4096 |
+
+最后一行是这次演示的工作上限,不是对 Astra 完整规格的声明。保存时让 Dify 做连接验证,成功后再建应用。截图中的网关只是本次测试环境,实际使用时换成你自己的服务。模型参数和可用接口以所选服务为准。模型名称以你选用的服务实际提供的标识为准,本文保留本次实测的 `gpt-6-astra`。
+
+
+
+*添加自定义模型。API Key 在截图前保持为空,实际运行时已配置。*
+
+
+## 创建一个足够简单的 Chatbot
+
+在 Dify Studio 点 **Create → Create from Blank → More basic app types → Chatbot**。命名 `Astra Writing Assistant`。这个需求只需要提示词和聊天记忆,用基础 Chatbot 就能做完整。
+
+
+
+*展开基础应用类型,选择 Chatbot。*
+
+
+
+*给写作助手命名,说明它负责什么。*
+
+在 **Configure model** 选 GPT-6 Astra,然后把下面的指令放到 Instructions:
+
+```text
+You help a community manager turn rough notes into messages people can use.
+Reply in the language of the user's request.
+For an announcement, give: one clear headline, a short ready-to-send message,
+and one optional reminder.
+Preserve supplied dates, times, links, and requirements exactly.
+Never invent missing event details, discounts, results, or promises.
+If an essential detail is missing, ask one concise question.
+Use a natural, direct tone. Avoid hype and excessive emojis.
+Keep a typical reply under 180 words. When revising, use conversation context.
+```
+
+我们特意把“保留时间和报名方式”写进了规则。群公告最怕的不是文笔一般,而是模型把晚上 8 点改成下午 8 点,或者顺手编出一个报名链接。指令用英文写不影响中文回复;`Reply in the language...` 会跟随用户语言。
+
+
+
+*写作助手的完整提示词编辑区。*
+
+先在右侧 **Debug & Preview** 输入:
+
+> 帮我写一条飞书群公告:9月22日晚上8点举行机器人分享会,每人带一个机器人案例,在群里回复“报名”即可参加。口吻自然,100字以内。
+
+实测回复保留了活动时间、案例要求和“报名”方式。接着可以继续说“删掉最后的提醒,只留两句话”。对于这种任务,第二轮是否记得上一轮,比第一次是否写得漂亮更有用。
+
+
+
+*Dify 中的中文公告回复,日期和报名方式来自输入。*
+
+英文测试也做了一遍:先要求写 Discord demo day 通知,再让它缩短并删除可选提醒。第二轮沿用了第一轮给定的日期、UTC 时间和频道。
+
+
+
+*多轮改写测试:继续修改已有公告。*
+
+基础 Chatbot 的改动需要通过 **Publish Update** 发布。不要只改了提示词就直接去平台测试,否则外部接口可能仍在使用上一个版本。
+
+
+
+*Access Point 同时提供 Web App 和 Backend Service API。*
+
+
+## 用 LangBot 接住消息入口
+
+在 Dify 点 **Publish**,发布当前版本。然后进入 **Access Point → Backend Service API → API Key**,创建这个应用自己的密钥。
+
+打开 LangBot,点 **Create Pipelines**,命名为 `Astra Writing Assistant`。进入 **Configuration → AI**:
+
+| 字段 | 值 |
+| --- | --- |
+| Runner | Dify Service API |
+| Base URL | `http://nginx/v1` |
+| App Type | Chat |
+| API Key | 刚创建的 Dify 应用密钥 |
+
+这里粘贴的是 **Dify 应用密钥**,不是前面配置 Astra 的模型密钥。一个负责调用应用,一个负责调用模型,别混用。保存后打开 **Debug Chat**,先验证整条调用链,再接平台。
+
+
+
+*LangBot 的 Dify Runner 配置;截图时尚未填入应用密钥。*
+
+
+## 在 LangBot 再试一次
+
+我们先发了一条英文 Telegram 提醒,拿到回复后继续要求改成中文飞书群提醒。最终回复保留了“9月22日晚上8点”和“每人带一个机器人案例”,说明 Dify 的会话上下文已经通过 LangBot 串起来了。
+
+
+
+*LangBot 调用 Dify 返回英文活动提醒。*
+
+实际使用时,可以把这类助手放到运营群或项目群里,用提及触发。它适合把草稿变成能发送的文本:活动预告、版本通知、会议提醒、FAQ 回复。消息发送前仍由群成员决定是否采用,这样也方便继续改稿。
+
+## 接到QQ、微信、飞书,以及其他消息平台
+
+在 LangBot 点 **Create Bots**,选择适配器,填写平台提供的凭据。创建后把机器人绑定到刚才的流水线;检查 Trigger 中的私聊、群聊和提及规则,再按平台要求发布或邀请机器人。
+
+本次实测覆盖 **LangBot → Dify → GPT-6 Astra → LangBot 回复**。以下是实际界面核对过的接入方式,平台账户与群聊没有在本次演示中授权连接。
+
+| 入口 | 配置方式 |
+| --- | --- |
+| 飞书 / Lark | 选 Lark;可扫码创建应用,也可手填 App ID、App Secret、与平台一致的 Bot Name。中国平台域名选 Feishu,默认使用长连接;开发者后台配置消息权限、事件并发布应用。 |
+| 钉钉 | 选 DingTalk;扫码或填写 Client ID、Client Secret。Robot Code 需从开发者后台复制;按文档配置 Stream 接收消息、机器人名称和需要的卡片模板。 |
+| 企业微信智能机器人 | 选 WeComBot;长连接用 BotId、Secret,填写机器人名称。Webhook 模式另配 Corpid、Token、EncodingAESKey。两套模式不要混填。 |
+| 企业微信内部应用 / 微信客服 | 分别选 WeCom 或 WeComCustomerService,按各自后台填写企业与应用凭据、Token、EncodingAESKey,完成 HTTPS 回调和可信 IP 配置。内部应用不能直接当群机器人使用。 |
+| 微信个人会话 | 选 OpenClaw WeChat,通过界面的二维码授权登录,按适配器说明接入。 |
+| 微信公众号 | 选 Official Account,填写 App ID、App Secret、Token、EncodingAESKey,配置公网回调与 IP 白名单。根据接口限制选择回复模式;耗时较长时可能需要用户再发一条消息取回结果。 |
+| QQ:OneBot v11 | 运行兼容 OneBot v11 的协议端,配置反向 WebSocket 到 LangBot 的监听端口,两侧 Access Token 保持一致;演示环境的 Compose 把主机 2288 映射到容器 2280。 |
+| QQ 官方机器人 | 选 QQ Official API;可扫码绑定或填写 App ID、Secret。当前界面说明 Token 可留空;接收模式、沙箱范围、发布审核以 QQ 官方及适配器文档为准。 |
+
+
+
+*飞书适配器支持域名选择、扫码创建和手工配置。*
+
+
+
+*QQ 的 OneBot v11 方案需要反向 WebSocket 监听地址和端口。*
+
+LangBot 的适配器列表还包括 Discord、Slack、Telegram、LINE、Mattermost、Matrix、KOOK、Satori、HTTP Bot、Page Bot 和 WeChatPad。每种适配器需要的权限、回调和协议不同,不是填一个通用 Token 就全部接通。完整入口见 [LangBot 平台文档](https://langbot.app/docs/zh/usage/platforms/readme)。
+
+如果只是想先模拟 QQ 消息,可以使用 matcha 之类的 OneBot 测试端;本文展示的对话来自 LangBot 自带 Debug Chat,没有把模拟对话当作真实群消息。
+
+## 几个容易卡住的地方
+
+
+遇到问题先按链路定位:Dify 预览能否回复、应用是否已 Publish、LangBot 的 Base URL 是否包含 `/v1`、应用类型是否正确,最后才看平台事件是否到达。如果改过 Dify 服务密码,Redis 与 Celery、Sandbox 与代码执行客户端的配置必须成对修改。
+
+本机部署还遇到过代理软件返回虚拟 DNS 地址,导致 Dify 的 SSRF 代理拒绝插件下载。修好 DNS 后恢复,没必要关掉证书校验或内网访问保护。一次外部 API 的临时失败也不能当作模型或平台已经接通的证明,重试后仍应检查运行记录。
+
+项目入口:[LangBot](https://github.com/langbot-app/LangBot) 和 [Dify](https://github.com/langgenius/dify)。截图和测试日期为 2026 年 9 月 16 日,后续版本的菜单名称可能会变化。
+
+
+## 继续阅读
+
+- [让客服问题走对分支:Dify + LangBot 钉钉、企业微信机器人实战](https://langbot.app/zh/blog/dify-langbot-support-triage)
+- [在飞书里查询 GitHub 更新:Dify Workflow + GPT-6 Astra + LangBot 实战](https://langbot.app/zh/blog/dify-langbot-github-release-briefing)
diff --git a/docs/zh/articles/index.mdx b/docs/zh/articles/index.mdx
index b1ef5828b..8f78a9373 100644
--- a/docs/zh/articles/index.mdx
+++ b/docs/zh/articles/index.mdx
@@ -7,14 +7,9 @@ description: "同步自 LangBot Blog 的产品动态、技术解析、教程、
## 产品动态
+- [LangBot 4.11:从消息回复走向事件驱动智能体](/zh/articles/langbot-v411-event-driven-agents) — 2026-10-15
- [LangBot Cloud 新版上线:多用户 Workspace 也来到开源版](/zh/articles/langbot-cloud-multi-user-workspaces) — 2026-08-25
-## 在线体验
-
-- [企业微信客服:体验 LangBot 知识库](/zh/articles/langbot-wecom-customer-service-knowledge-base) — 2026-09-17
-- [Telegram:体验 LangBot 沙箱 Agent](/zh/articles/langbot-telegram-sandbox-agent) — 2026-09-17
-- [LangBot 公众号:体验基础 AI 问答](/zh/articles/langbot-wechat-official-account) — 2026-09-17
-
## 技术解析
- [LangBot v4.10.0:Agent 沙箱与技能——让 Agent 真正像开发者一样工作](/zh/articles/langbot-v4100-sandbox-skills) — 2026-06-04
@@ -23,6 +18,9 @@ description: "同步自 LangBot Blog 的产品动态、技术解析、教程、
## 教程与集成
+- [在飞书里查询 GitHub 更新:Dify Workflow + GPT-6 Astra + LangBot 实战](/zh/articles/dify-langbot-github-release-briefing) — 2026-09-16
+- [让客服问题走对分支:Dify + LangBot 钉钉、企业微信机器人实战](/zh/articles/dify-langbot-support-triage) — 2026-09-16
+- [把群公告交给 Dify:用 GPT-6 Astra 和 LangBot 搭建 QQ、飞书写作助手](/zh/articles/dify-langbot-writing-assistant) — 2026-09-16
- [LangTARS:开源 AI Agent 远程控制电脑 — 支持接入 Dify 和 n8n 工作流](/zh/articles/langtars-remote-pc-control-dify-n8n) — 2026-03-23
- [5分钟将 DeepSeek R1 接入微信、企业微信与 Discord (免费教程)](/zh/articles/connect-deepseek-to-wechat) — 2026-02-21
- [5 分钟部署你的 AI 机器人到 Discord、Telegram 和微信](/zh/articles/deploy-ai-bot-in-5-minutes) — 2026-02-17
diff --git a/docs/zh/articles/langbot-v411-event-driven-agents.mdx b/docs/zh/articles/langbot-v411-event-driven-agents.mdx
new file mode 100644
index 000000000..1bdbb43e6
--- /dev/null
+++ b/docs/zh/articles/langbot-v411-event-driven-agents.mdx
@@ -0,0 +1,132 @@
+---
+title: "LangBot 4.11:从消息回复走向事件驱动智能体"
+description: "LangBot 4.11 正式发布:事件路由让机器人响应入群、回应与卡片交互,AgentRunner 插件化连接不同智能体,工作区助手让配置更直观。附实机截图与升级指南。"
+---
+
+本文同步自 LangBot Blog。 [阅读 Blog 原文](https://langbot.app/zh/blog/langbot-v411-event-driven-agents).
+
+发布于 2026-10-15 · 作者:LangBot Team
+
+LangBot 是一个开源的 AI 聊天机器人与智能体平台,支持多种即时通讯平台、大语言模型、知识库和插件,帮助你构建、部署和管理 AI 助手。
+
+过去,搭建一个聊天机器人通常从“收到消息,然后回复”开始。但真正的协作不只发生在消息里:新成员加入群组、用户对一条回复作出表情回应、同事点击卡片按钮,都可能是一个任务的起点。
+
+**LangBot 4.11 的核心变化,是把这些事件变成可以配置、交给智能体处理、并追踪执行过程的入口。** 配合插件化的 AgentRunner 和新的工作区助手,LangBot 在连接聊天平台的同时,也让“由谁处理、怎么处理、如何管理”变得更清楚。
+
+## 一、事件驱动:机器人不再只等待下一条消息
+
+4.11 引入事件驱动智能体(Event-Based Agents,EBA)。除了普通消息,入群、退群、消息撤回、表情回应、卡片点击等平台事件,也可以进入处理流程。本次发布中,13 个平台适配器已经基于 EBA 架构重写。
+
+这不意味着每个平台都有完全相同的事件能力。实际能收到哪些事件,仍取决于对应平台、适配器以及机器人权限。变化在于:收到事件之后,LangBot 有了一套统一的路由和处理方式。
+
+
+
+*4.11 正式版发布说明中的事件路由示例。*
+
+### 从场景出发,而不是先写一段胶水代码
+
+在「事件路由」中,可以从欢迎新成员、处理成员离开等常见场景建立规则,再决定由哪个处理器执行。
+
+例如,一个社区可以把普通问题交给知识库问答 Pipeline,把新成员加入交给负责欢迎与介绍规则的 Agent,再把需要固定业务逻辑的事件交给插件处理器。这是配置思路示例;实际启用前,需要确认平台事件支持和所需权限,并测试对应路由。
+
+这种组织方式的好处是,**平台接入与业务处理不必绑成一块**。欢迎流程变了,可以调整处理器;问答策略变了,可以修改 Pipeline,而不必把所有行为塞进同一段提示词。
+
+### Pipeline、Agent、插件处理器,分别适合什么?
+
+
+
+*本文新启动的 LangBot 4.11.0 实例:创建处理器时,先选择处理方式。*
+
+- **Pipeline**:处理消息事件,适合直接生成回复、知识库问答和已有的消息处理流程。它仍是固定步骤清晰、容易配置的选择。
+- **Agent**:用自然语言描述不同事件该如何处理,由 AI 执行,或者交给外部 Agent 平台。适合需要结合上下文作判断的任务。
+- **插件处理器**:由插件代码处理其声明支持的事件类型。适合规则明确、需要确定性逻辑或专门集成的场景。
+
+不需要为了“用上智能体”而把每个流程都改成 Agent。明确的规则交给代码,稳定的问答交给 Pipeline,需要判断的部分再交给 Agent,往往更容易维护。
+
+### 调试也围绕事件展开
+
+事件调试器允许以表单编辑事件数据,查看运行日志和执行详情。配置好路由之后,可以先检查事件字段和处理结果,再到真实聊天平台验证。调试事件有助于定位问题,但不替代对平台连接、权限和真实回调的验证。
+
+平台侧也有相应改进,包括 Slack Socket Mode、钉钉卡片交互、微信“正在输入”提示和 OneBot WebSocket 等;具体支持范围以对应平台文档为准。
+
+## 二、AgentRunner 插件化:选择执行方式,不必等待 Core 发版
+
+另一项重要变化是运行器与主程序的解耦。LocalAgent、Dify、Coze、n8n 等原内置运行器迁移为扩展市场中的官方插件,并新增 Codex、Claude Code、ACP 等运行器。
+
+
+
+*实机访问扩展市场,可看到官方 Runner 专区。市场内容独立更新,展示状态以访问时为准。*
+
+对于用户,最直接的变化是:**按需安装运行器,并独立更新它们**。你可以使用 LocalAgent 的模型、工具和知识检索能力,也可以把已有的 Dify 应用或 n8n 工作流接到聊天平台,不必为了不同的执行方式维护不同的机器人接入方案。
+
+这也给开发者留下更清晰的扩展边界:实现 AgentRunner Protocol v1,就可以编写自己的运行器,而不必把特定平台的逻辑直接加入 Core。
+
+
+
+*4.11 正式版发布说明中的运行器插件示例。不同 Runner 的账号、API、运行环境要求仍需分别配置。*
+
+插件化并不等于把所有能力无条件交给插件。运行器只能调用当前上下文授权的工具与平台 API;扩展市场展示认证徽章,Core 校验认证插件的签名。认证与权限控制解决的是来源和授权问题,使用前仍应阅读插件说明,确认它适合自己的部署环境。
+
+### 升级时,已有 Pipeline 怎么办?
+
+4.11 提供迁移入口,自动安装所需插件,并备份原配置。升级后,在「流水线」页面完成迁移,再检查运行器、模型、知识库和工具配置。
+
+需要特别留意:**迁移后的流水线会开始新会话**。旧版自定义 Runner 也需要改写为插件 Runner。离线环境可以选择「仅迁移数据」,再手动安装运行器插件。完整映射关系见 [4.11 迁移说明](https://github.com/langbot-app/LangBot/blob/v4.11.0/docs/pipeline-migration-config-map.zh-CN.md)。
+
+## 三、工作区助手:把配置过程变成可确认的对话
+
+WebUI 新增的工作区助手,面向的是管理 LangBot 的人,而不是聊天群中的终端用户。它可以查询当前工作区的资源,并通过对话帮助创建和配置流水线、知识库等资源。
+
+
+
+*实机中的工作区助手初始界面。此截图展示尚未配置模型的新工作区;使用前需要登录 LangBot Account 或配置可用模型。监控中的零值是这个新实例的真实空状态。*
+
+你可以从“当前有哪些模型、知识库和流水线?”这样的只读查询开始,再提出“帮我创建一个知识库问答应用”。重点不是把表单换成聊天框,而是让助手先理解当前资源,再提出具体配置。
+
+**所有写操作都需要确认后才会执行。** 因此,在让助手新建或修改资源时,应先核对它提出的对象、参数和影响范围,再确认。助手降低了寻找配置入口的成本,也保留了管理者对变更的控制。
+
+## 四、让日常运维跟得上新的执行方式
+
+### 统一看执行,而不只看消息数量
+
+当一个事件可能由 Pipeline、Agent 或插件处理时,排查问题需要看到完整的执行过程。4.11 更新了统一执行视图、工具调用追踪和实时处理中面板。
+
+日常检查可以按这条顺序进行:先看事件有没有进入,再看交给了哪个处理器,最后查看模型与工具调用是否完成。这样更容易区分平台接入问题、路由配置问题和执行阶段的问题,而不是仅凭“机器人没有回复”猜测原因。
+
+### 账号安全与操作记录
+
+
+
+*实机账号设置展示 Passkey 和两步验证入口;截图中的演示账号尚未启用这些功能。*
+
+4.11 新增 WebAuthn Passkey 登录和 TOTP 两步验证,并提供记录每次操作变更的操作日志及完整性校验。对于多人维护的工作区,这些能力分别帮助加强登录保护、追踪“谁改了什么”。Passkey 的使用还需要满足浏览器安全上下文等条件,应在合适的 HTTPS 部署环境中配置。
+
+媒体存储也有调整:媒体按内容去重存储,减少重复媒体导致的 `langbot.db` 膨胀。此外,`lbctl` 和 API Key 提供了管理与诊断实例的命令行入口,适合把日常检查接入已有运维流程。
+
+## 五、新用户更容易开始,老用户按步骤升级
+
+
+
+*新手引导从平台选择开始,再进入机器人配置与 AI 引擎设置。*
+
+第一次使用 LangBot,可以跟随侧边栏引导和场景化机器人创建向导,从平台、机器人配置、AI 引擎逐步完成设置。建议先验证一个简单问答流程,再添加知识库、工具和事件路由,避免同时引入太多变量。
+
+已有实例升级,建议按下面的顺序处理:
+
+1. **先备份** `data/` 目录或 PostgreSQL 数据库,建议从最新 v4.10.x 升级,并保留当前部署配置。
+2. **升级 Core 与配套 Runtime**,检查服务连接状态。4.11.0 正式版固定使用 SDK `0.7.11`,不要混用旧 Runtime。
+3. **完成流水线迁移并更新插件**,检查迁移后的运行器及配置,注意新会话边界。
+4. **验证真实业务链路**:先测试一个已有的消息问答,再逐步验证新事件路由和授权工具。
+5. **检查遥测与配置变化**:新增执行遥测默认开启,不含消息内容;可用 `space.disable_telemetry: true` 关闭这一遥测。配置新增 `media_cache`、`api.host`、`runner.admin_plugins`,移除 `force_box_session_id_template`。详见[数据收集政策](https://langbot.app/docs/zh/insight/data-collection-policy)及正式发布说明。
+
+## 开始使用 LangBot 4.11
+
+4.11 的价值不在于新增了多少配置项,而在于把三个问题连起来:**什么事件触发任务、由什么执行方式处理、怎样安全地管理和观察它。** 你可以继续保留熟悉的消息问答,也可以逐步把社区协作、业务通知与交互任务放进同一套管理界面。
+
+- [查看 v4.11.0 正式版与完整更新说明](https://github.com/langbot-app/LangBot/releases/tag/v4.11.0)
+- [阅读部署与使用文档](https://langbot.app/docs/zh/insight/guide)
+- [使用 LangBot Cloud](https://cloud.langbot.app)
+
+感谢所有参与本次版本开发、测试、文档和问题反馈的贡献者。本文围绕主要更新展开,完整贡献者名单见正式发布说明。
+
+*配图说明:事件路由与运行器插件总览来自 v4.11.0 GitHub Release,其余五张截图来自为本文独立运行的官方 `rockchin/langbot:v4.11.0` 实例。实机截图使用英文界面便于跨语言对照;没有连接生产机器人,也没有将演示界面描述为真实业务运行结果。*
diff --git a/docs/zh/articles/langflow-drag-and-drop-ai-bot.mdx b/docs/zh/articles/langflow-drag-and-drop-ai-bot.mdx
index 8f832f693..3ef8137c2 100644
--- a/docs/zh/articles/langflow-drag-and-drop-ai-bot.mdx
+++ b/docs/zh/articles/langflow-drag-and-drop-ai-bot.mdx
@@ -120,9 +120,9 @@ http://localhost:7860/flow/{flow-id}

-配置**运行器**,选择 **Langflow API**:
+配置 **Runner**,选择 **Langflow API**:
-
+
填写 Langflow 配置:
diff --git a/docs/zh/articles/n8n-multi-platform-ai-chatbot.mdx b/docs/zh/articles/n8n-multi-platform-ai-chatbot.mdx
index ae848000a..1b082d3f5 100644
--- a/docs/zh/articles/n8n-multi-platform-ai-chatbot.mdx
+++ b/docs/zh/articles/n8n-multi-platform-ai-chatbot.mdx
@@ -101,7 +101,7 @@ http://localhost:5678/webhook/{your-webhook-id}

-切换到 **AI** 标签页,在运行器下拉菜单中选择 "n8n Workflow API"。配置:
+切换到 **AI** 标签页,在 Runner 下拉菜单中选择 "n8n Workflow API"。配置:
- **Webhook URL**:粘贴你的 n8n 生产环境 webhook URL
- **Authentication Type**:None(与 n8n webhook 设置保持一致)
diff --git a/docs/zh/plugin/dev/tutor.mdx b/docs/zh/plugin/dev/tutor.mdx
index 089977061..612b7c4c0 100644
--- a/docs/zh/plugin/dev/tutor.mdx
+++ b/docs/zh/plugin/dev/tutor.mdx
@@ -111,7 +111,7 @@ cd HelloPlugin
-- `Stdio` 模式:当您使用源代码启动 LangBot 并未携带启动参数`--standalone-runtime`时,LangBot 会自动以子进程的形式启动 Plugin Runtime,并通过 `stdio`(标准输入输出流)模式与 Plugin Runtime 通信。此时 Runtime 会加载 LangBot 根目录的`data/plugins`目录下的插件。并监听 LangBot 运行主机的`5401`端口作为调试端口。
+- `Stdio` 模式:当您使用源代码启动 LangBot 并未携带启动参数`--standalone-runtime`时,LangBot 会自动以子进程的形式启动 Plugin Runtime,并通过 `stdio`(标准输入输出流)模式与 Plugin Runtime 通信。此时 Runtime 会加载 LangBot 根目录的`data/plugins`目录下的插件。并监听 LangBot 运行主机上的调试端口(默认`5401`,可由配置项`plugin.display_plugin_debug_url`中的端口覆盖)。
- `WebSocket` 模式:
- 生产环境:当您使用官方提供的`docker-compose.yaml`启动 LangBot 时,会在独立的容器运行 Plugin Runtime,LangBot 也会因为携带启动参数`--standalone-runtime`而以 WebSocket 模式与 Plugin Runtime 通信。默认情况下,Runtime 容器的 5401 端口会被映射到宿主机的 5401 端口。
diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py
index e9982dd83..4fedae5ca 100644
--- a/src/langbot/libs/qq_official_api/api.py
+++ b/src/langbot/libs/qq_official_api/api.py
@@ -352,6 +352,7 @@ class QQOfficialClient:
return {}
message_data = {
't': msg.get('t', {}),
+ 'bot_account_id': self.app_id,
'user_openid': d.get('author', {}).get('user_openid', {}),
'timestamp': d.get('timestamp', {}),
'd_author_id': d.get('author', {}).get('id', {}),
@@ -361,9 +362,27 @@ class QQOfficialClient:
'channel_id': d.get('channel_id', {}),
'username': d.get('author', {}).get('username', {}),
'guild_id': d.get('guild_id', {}),
- 'member_openid': d.get('author', {}).get('openid', {}),
+ 'member_openid': d.get('author', {}).get('member_openid') or d.get('author', {}).get('openid') or '',
'group_openid': d.get('group_openid', {}),
}
+ # Full-group mode delivers native bot mentions through this event too.
+ # Only QQ's explicit self marker and a matching token authorize an At.
+ if msg.get('t') == 'GROUP_MESSAGE_CREATE':
+ content = d.get('content')
+ mentions = d.get('mentions')
+ if isinstance(content, str) and isinstance(mentions, list):
+ for mention in mentions:
+ if not isinstance(mention, dict) or mention.get('is_you') is not True:
+ continue
+ for key in ('id', 'member_openid', 'openid'):
+ bot_id = mention.get(key)
+ if not isinstance(bot_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', bot_id):
+ continue
+ for token in (f'<@{bot_id}>', f'<@!{bot_id}>'):
+ if token in content:
+ content = content.replace(token, '')
+ message_data['is_at_bot'] = True
+ message_data['content'] = content
attachments = d.get('attachments', [])
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [
diff --git a/src/langbot/libs/qq_official_api/qqofficialevent.py b/src/langbot/libs/qq_official_api/qqofficialevent.py
index 7c29b9d8f..852f814fe 100644
--- a/src/langbot/libs/qq_official_api/qqofficialevent.py
+++ b/src/langbot/libs/qq_official_api/qqofficialevent.py
@@ -17,6 +17,13 @@ class QQOfficialEvent(dict):
"""
return self.get('t', '')
+ @property
+ def is_at_bot(self) -> bool:
+ """Whether QQ identified a native mention of the current bot."""
+ return self.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'} or (
+ self.t == 'GROUP_MESSAGE_CREATE' and self.get('is_at_bot') is True
+ )
+
@property
def user_openid(self) -> str:
"""
@@ -85,7 +92,7 @@ class QQOfficialEvent(dict):
"""
成员openid
"""
- return self.get('openid', {})
+ return self.get('member_openid') or self.get('openid') or ''
@property
def attachments(self) -> str:
diff --git a/src/langbot/pkg/agent/runner/interaction_store.py b/src/langbot/pkg/agent/runner/interaction_store.py
index ccd8178b9..22bc21eb6 100644
--- a/src/langbot/pkg/agent/runner/interaction_store.py
+++ b/src/langbot/pkg/agent/runner/interaction_store.py
@@ -69,8 +69,11 @@ def _epoch_to_datetime(value: int | float | None) -> datetime.datetime | None:
if value is None:
return None
try:
- return datetime.datetime.fromtimestamp(float(value), UTC)
- except (TypeError, ValueError, OSError) as exc:
+ ts = float(value)
+ if ts > 1e11:
+ ts /= 1000.0
+ return datetime.datetime.fromtimestamp(ts, UTC)
+ except (TypeError, ValueError, OSError, OverflowError) as exc:
raise ValueError('expires_at must be a valid epoch timestamp') from exc
diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py
index ed288ff55..e0724f841 100644
--- a/src/langbot/pkg/agent/runner/run_journal.py
+++ b/src/langbot/pkg/agent/runner/run_journal.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import datetime
+import math
import typing
from ...core import app
@@ -39,6 +41,21 @@ class AgentRunJournal:
return dict(value)
return {}
+ @staticmethod
+ def _event_time_to_utc(value: typing.Any) -> datetime.datetime | None:
+ """Normalize legacy second/millisecond/microsecond Unix timestamps to UTC datetime."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return None
+ try:
+ timestamp = float(value)
+ if timestamp == 0 or not math.isfinite(timestamp):
+ return None
+ while abs(timestamp) > 10_000_000_000:
+ timestamp /= 1000
+ return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
+ except (TypeError, ValueError, OSError, OverflowError):
+ return None
+
@classmethod
def _sanitize_content_item(cls, value: typing.Any) -> typing.Any:
item = cls._to_plain_dict(value)
@@ -258,8 +275,6 @@ class AgentRunJournal:
metadata: dict[str, typing.Any] | None = None,
) -> str:
"""Write incoming event to EventLog."""
- import datetime
-
from .event_log_store import EventLogStore
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
@@ -292,9 +307,7 @@ class AgentRunJournal:
input_json=input_json,
run_id=run_id,
runner_id=runner_id,
- event_time=(
- datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None
- ),
+ event_time=self._event_time_to_utc(event.event_time),
metadata={**(metadata or {}), 'input_event': self._sanitize_event_data(event.data)},
)
@@ -351,7 +364,6 @@ class AgentRunJournal:
if not items:
return
- import datetime
import uuid
from .event_log_store import EventLogStore
@@ -364,16 +376,7 @@ class AgentRunJournal:
actor = item.get('actor') if isinstance(item.get('actor'), dict) else {}
subject = item.get('subject') if isinstance(item.get('subject'), dict) else {}
- event_time = None
- raw_event_time = event.get('event_time')
- if raw_event_time:
- try:
- event_time = datetime.datetime.fromtimestamp(
- raw_event_time,
- datetime.timezone.utc,
- )
- except (TypeError, ValueError, OSError):
- event_time = None
+ event_time = self._event_time_to_utc(event.get('event_time'))
await store.append_event(
event_id=str(uuid.uuid4()),
diff --git a/src/langbot/pkg/agent/runner/run_ledger_store.py b/src/langbot/pkg/agent/runner/run_ledger_store.py
index 6be6aaab2..ade45de91 100644
--- a/src/langbot/pkg/agent/runner/run_ledger_store.py
+++ b/src/langbot/pkg/agent/runner/run_ledger_store.py
@@ -50,8 +50,11 @@ def _epoch_to_datetime(value: typing.Any) -> datetime.datetime | None:
if value is None:
return None
try:
- return as_naive_utc(datetime.datetime.fromtimestamp(float(value), UTC))
- except (TypeError, ValueError, OSError):
+ ts = float(value)
+ if ts > 1e11:
+ ts /= 1000.0
+ return as_naive_utc(datetime.datetime.fromtimestamp(ts, UTC))
+ except (TypeError, ValueError, OSError, OverflowError):
return None
diff --git a/src/langbot/pkg/pipeline/bansess/bansess.py b/src/langbot/pkg/pipeline/bansess/bansess.py
index a1cad2b1d..6ed3631e4 100644
--- a/src/langbot/pkg/pipeline/bansess/bansess.py
+++ b/src/langbot/pkg/pipeline/bansess/bansess.py
@@ -31,7 +31,7 @@ class BanSessionCheckStage(stage.PipelineStage):
found = True
break
# 使用 *_id 来表示加白/拉黑某用户的私聊和群聊场景
- if sess.startswith('*_') and (sess[2:] == query.launcher_id or sess[2:] == query.sender_id):
+ if sess.startswith('*_') and (sess[2:] == str(query.launcher_id) or sess[2:] == str(query.sender_id)):
found = True
break
diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py
index 5226d56b0..274c6fcee 100644
--- a/src/langbot/pkg/pipeline/process/handlers/chat.py
+++ b/src/langbot/pkg/pipeline/process/handlers/chat.py
@@ -15,6 +15,11 @@ from ... import plugin_diagnostics
import langbot_plugin.api.entities.events as events
from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner import config_schema
+from ....agent.runner.errors import (
+ RunnerExecutionError,
+ RunnerNotFoundError,
+ RunnerNotAuthorizedError,
+)
from ....utils import runner as runner_utils
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -119,9 +124,24 @@ class ChatMessageHandler(handler.MessageHandler):
'try_claim_steering_from_query',
None,
)
- if try_claim_steering and await try_claim_steering(query):
- yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
- return
+ if try_claim_steering:
+ try:
+ claimed = await try_claim_steering(query)
+ except RunnerNotFoundError as steering_error:
+ # Steering is an optimization for an already-running
+ # agent. A runtime reconnect can temporarily remove the
+ # selected Runner from the registry; that must not
+ # prevent the message from starting a normal run.
+ self.ap.logger.warning(
+ 'Skipping steering for query %s because Runner %s is not registered',
+ query.query_id,
+ steering_error.runner_id,
+ )
+ claimed = False
+
+ if claimed:
+ yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
+ return
try:
is_stream = await query.adapter.is_stream_output_supported()
@@ -228,13 +248,6 @@ class ChatMessageHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
except Exception as e:
- # Import orchestrator errors for specific handling
- from ....agent.runner.errors import (
- RunnerNotFoundError,
- RunnerNotAuthorizedError,
- RunnerExecutionError,
- )
-
error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
index 7703a5935..d8dcad0c9 100644
--- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
+++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
@@ -439,7 +439,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
def _reply_target(event: QQOfficialEvent) -> tuple[str, str]:
if event.t == 'C2C_MESSAGE_CREATE':
return 'person', event.user_openid
- if event.t == 'GROUP_AT_MESSAGE_CREATE':
+ if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}:
return 'group', event.group_openid
if event.t == 'AT_MESSAGE_CREATE':
return 'channel', event.channel_id
diff --git a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py
index 66b550418..98344dc39 100644
--- a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py
+++ b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py
@@ -16,6 +16,7 @@ MESSAGE_EVENT_TYPES = {
'C2C_MESSAGE_CREATE',
'DIRECT_MESSAGE_CREATE',
'GROUP_AT_MESSAGE_CREATE',
+ 'GROUP_MESSAGE_CREATE',
'AT_MESSAGE_CREATE',
}
REACTION_ADD_EVENT_TYPES = {'MESSAGE_REACTION_ADD'}
@@ -89,7 +90,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
chat_type = platform_entities.ChatType.PRIVATE
chat_id = self._private_chat_id(event)
group = None
- if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
+ if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
chat_type = platform_entities.ChatType.GROUP
chat_id = event.channel_id if event.t == 'AT_MESSAGE_CREATE' else event.group_openid
chat_id = chat_id or event.group_openid or event.channel_id or ''
@@ -205,7 +206,7 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
@staticmethod
def _sender_id(event: QQOfficialEvent) -> str:
member_openid = event.member_openid or event.get('member_openid', '')
- if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
+ if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
return member_openid or event.user_openid or event.d_author_id or ''
return event.user_openid or member_openid or event.d_author_id or event.guild_id or event.group_openid or ''
diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
index 943068e60..545046f1a 100644
--- a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
+++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
@@ -70,8 +70,8 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
platform_message.Source(id=event.d_id or event.id or '', time=_parse_timestamp(event.timestamp)),
]
- if event.t in {'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}:
- components.append(platform_message.At(target='justbot'))
+ if event.is_at_bot:
+ components.append(platform_message.At(target=event.get('bot_account_id') or 'justbot'))
if event.attachments:
try:
diff --git a/src/langbot/pkg/platform/sources/http_bot.py b/src/langbot/pkg/platform/sources/http_bot.py
index 72c99d1c8..0aa8ac950 100644
--- a/src/langbot/pkg/platform/sources/http_bot.py
+++ b/src/langbot/pkg/platform/sources/http_bot.py
@@ -99,6 +99,7 @@ class _SyncCollector:
class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Standalone HTTP adapter (inbound webhook + outbound callbacks)."""
+ ap: typing.Any = pydantic.Field(default=None, exclude=True)
bot_uuid: str = pydantic.Field(default='', exclude=True)
listeners: dict[
@@ -377,7 +378,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
- """Drop the matching session so the next message starts a fresh conversation."""
+ """Reset scoped Runner state and history through the shared context service."""
execution_context = getattr(self.logger, 'execution_context', None)
if (
execution_context is None
@@ -387,30 +388,13 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
or not self.bot_uuid
):
raise RuntimeError('http_bot reset requires a trusted execution scope')
- expected_prefix = (
- execution_context.instance_uuid,
- execution_context.workspace_uuid,
- execution_context.placement_generation,
- self.bot_uuid,
- launcher_type,
- )
-
- sess_mgr = self.ap.sess_mgr
- before = len(sess_mgr.session_list)
- sess_mgr.session_list = [
- s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id)
- ]
- return len(sess_mgr.session_list) < before
-
- @staticmethod
- def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool:
- session_key = getattr(session, '_langbot_session_key', None)
- return (
- isinstance(session_key, tuple)
- and len(session_key) == 6
- and session_key[:5] == expected_prefix
- and str(session_key[5]) == launcher_id
- )
+ try:
+ await self.ap.monitoring_service.reset_session_context(
+ execution_context, self.bot_uuid, f'{launcher_type}_{launcher_id}'
+ )
+ except LookupError:
+ return False
+ return True
# -- outbound -------------------------------------------------------------
diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py
index f012b07f9..fcc4c1a93 100644
--- a/src/langbot/pkg/platform/sources/qqofficial.py
+++ b/src/langbot/pkg/platform/sources/qqofficial.py
@@ -146,11 +146,12 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
remark='',
)
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event)
- if event.t == 'GROUP_AT_MESSAGE_CREATE':
- yiri_chain.insert(0, platform_message.At(target='justbot'))
+ if event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}:
+ if event.is_at_bot:
+ yiri_chain.insert(0, platform_message.At(target='justbot'))
sender = platform_entities.GroupMember(
- id=event.group_openid,
+ id=event.member_openid,
member_name=event.t,
permission='MEMBER',
group=platform_entities.Group(
@@ -300,7 +301,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
target_type = 'c2c'
target_id = qq_official_event.user_openid
- elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
+ elif qq_official_event.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}:
target_type = 'group'
target_id = qq_official_event.group_openid
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
@@ -430,6 +431,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
self.bot.on_message('C2C_MESSAGE_CREATE')(on_message)
elif event_type == platform_events.GroupMessage:
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
+ self.bot.on_message('GROUP_MESSAGE_CREATE')(on_message)
self.bot.on_message('AT_MESSAGE_CREATE')(on_message)
def set_bot_uuid(self, bot_uuid: str):
@@ -476,6 +478,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
'C2C_MESSAGE_CREATE',
'DIRECT_MESSAGE_CREATE',
'GROUP_AT_MESSAGE_CREATE',
+ 'GROUP_MESSAGE_CREATE',
'AT_MESSAGE_CREATE',
}
if event_type not in message_event_types:
@@ -823,7 +826,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return None
if source.t == 'C2C_MESSAGE_CREATE':
return 'c2c', source.user_openid
- if source.t == 'GROUP_AT_MESSAGE_CREATE':
+ if source.t in {'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'}:
return 'group', source.group_openid
if source.t == 'AT_MESSAGE_CREATE':
return 'channel', source.channel_id
diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py
index b880e86da..9a0b69886 100644
--- a/src/langbot/pkg/plugin/connector.py
+++ b/src/langbot/pkg/plugin/connector.py
@@ -1121,13 +1121,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# a managed runtime and authenticate its WebSocket controller.
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
control_headers = self._control_headers(allow_generate=True)
+ control_port, debug_port = self._managed_runtime_ports()
await self._start_runtime_subprocess(
'-m',
'langbot_plugin.cli.__init__',
'rt',
+ '--ws-control-port',
+ str(control_port),
+ '--ws-debug-port',
+ str(debug_port),
env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
)
- ws_url = 'ws://localhost:5400/control/ws'
+ ws_url = f'ws://localhost:{control_port}/control/ws'
async def connection_failed(
ctrl: ws_client_controller.WebSocketClientController,
@@ -1145,9 +1150,17 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
task_coro = self.ctrl.run(new_connection_callback)
else:
self.ap.logger.info('use stdio to connect to plugin runtime')
+ _, debug_port = self._managed_runtime_ports()
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
- args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
+ args=[
+ '-m',
+ 'langbot_plugin.cli.__init__',
+ 'rt',
+ '-s',
+ '--ws-debug-port',
+ str(debug_port),
+ ],
env=os.environ.copy(),
capture_stderr=False,
)
@@ -1235,14 +1248,43 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
await self.runtime_ops_reporter.stop()
await self._close_managed_subprocess()
+ @staticmethod
+ def _runtime_port_from_url(url: str, *, default: int) -> int:
+ """Extract a TCP port from a WebSocket/HTTP URL, falling back to *default*."""
+ try:
+ parsed = urlparse(url if '://' in url else f'//{url}')
+ return parsed.port or default
+ except (TypeError, ValueError):
+ return default
+
@staticmethod
def _runtime_debug_port_from_url(debug_url: str) -> int:
"""Extract the local plugin runtime debug port from its display URL."""
- try:
- parsed = urlparse(debug_url if '://' in debug_url else f'//{debug_url}')
- return parsed.port or 5401
- except (TypeError, ValueError):
- return 5401
+ return PluginRuntimeConnector._runtime_port_from_url(debug_url, default=5401)
+
+ @staticmethod
+ def _runtime_control_port_from_url(control_url: str) -> int:
+ """Extract the local plugin runtime control port from its WebSocket URL."""
+ return PluginRuntimeConnector._runtime_port_from_url(control_url, default=5400)
+
+ def _managed_runtime_ports(self) -> tuple[int, int]:
+ """Resolve control/debug ports for a Core-managed Runtime subprocess.
+
+ Docker/standalone deployments keep listening on their own configured
+ ports; this helper only affects the stdio and Windows launch paths,
+ where LangBot starts Runtime itself. The debug port follows
+ ``plugin.display_plugin_debug_url`` so a second Core instance can avoid
+ ``Address already in use`` on the default 5401 without editing CLI
+ flags by hand. The control port follows ``plugin.runtime_ws_url`` for
+ the Windows WebSocket path.
+ """
+ plugin_config = self.ap.instance_config.data.get('plugin') or {}
+ control_url = plugin_config.get('runtime_ws_url') or 'ws://localhost:5400/control/ws'
+ debug_url = plugin_config.get('display_plugin_debug_url') or 'ws://localhost:5401/plugin/debug/ws'
+ return (
+ self._runtime_control_port_from_url(str(control_url)),
+ self._runtime_debug_port_from_url(str(debug_url)),
+ )
async def initialize_plugins(self):
pass
diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml
index 9116b1040..34e219e5b 100644
--- a/src/langbot/templates/config.yaml
+++ b/src/langbot/templates/config.yaml
@@ -289,6 +289,9 @@ plugin:
connect_timeout_seconds: 180.0
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
+ # Console debug endpoint; its port is also passed to managed Runtime
+ # subprocesses (stdio / Windows) as --ws-debug-port so a second Core
+ # instance can listen elsewhere without Address already in use.
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
certification:
# Non-secret Ed25519 issuer key ring used to verify the SDK ZIP-comment
diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py
index 8e7276d8c..9066948dd 100644
--- a/tests/unit_tests/agent/test_chat_handler.py
+++ b/tests/unit_tests/agent/test_chat_handler.py
@@ -140,6 +140,39 @@ class MockAgentRunOrchestrator:
return 'plugin:langbot-team/LocalAgent/default'
+class SteeringRunnerNotFoundOrchestrator(MockAgentRunOrchestrator):
+ async def try_claim_steering_from_query(self, query):
+ raise RunnerNotFoundError('plugin:langbot-team/LocalAgent/default')
+
+
+class SteeringClaimedOrchestrator(MockAgentRunOrchestrator):
+ def __init__(self):
+ super().__init__()
+ self.run_called = False
+
+ async def try_claim_steering_from_query(self, query):
+ return True
+
+ async def run_from_query(self, query):
+ self.run_called = True
+ if False:
+ yield None
+
+
+class SteeringUnexpectedErrorOrchestrator(MockAgentRunOrchestrator):
+ async def try_claim_steering_from_query(self, query):
+ raise RuntimeError('steering lookup failed')
+
+
+class PersistentRunnerGapOrchestrator(SteeringRunnerNotFoundOrchestrator):
+ """Steering and the normal run both miss the Runner (a lasting gap)."""
+
+ async def run_from_query(self, query):
+ raise RunnerNotFoundError('plugin:langbot-team/LocalAgent/default')
+ if False:
+ yield None
+
+
class MockApplication:
"""Mock Application for testing."""
@@ -441,6 +474,141 @@ class TestChatHandlerAsyncBehavior:
assert results[0].result_type == entities.ResultType.INTERRUPT
assert 'not found' in results[0].user_notice
+ @pytest.mark.asyncio
+ async def test_runner_not_found_during_steering_falls_back_to_normal_run(self):
+ """A transient registry miss during steering must not drop the message."""
+ from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
+ from langbot.pkg.pipeline import entities
+
+ orchestrator = SteeringRunnerNotFoundOrchestrator(
+ chunks=[MagicMock(content='ok', tool_calls=[], is_final=True)]
+ )
+ mock_ap = MockApplication(orchestrator=orchestrator)
+ mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
+ handler = ChatMessageHandler(mock_ap)
+ query = MockQuery()
+
+ mock_event = MagicMock()
+ mock_event.return_value = MagicMock()
+
+ def make_result(*args, **kwargs):
+ return MagicMock(
+ result_type=kwargs.get('result_type'),
+ user_notice=kwargs.get('user_notice'),
+ )
+
+ with (
+ patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
+ patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
+ ):
+ mock_events_module.PersonNormalMessageReceived = mock_event
+ mock_events_module.GroupNormalMessageReceived = mock_event
+ results = [result async for result in handler.handle(query)]
+
+ assert results
+ assert all(result.result_type != entities.ResultType.INTERRUPT for result in results)
+ mock_ap.logger.warning.assert_any_call(
+ 'Skipping steering for query %s because Runner %s is not registered',
+ query.query_id,
+ 'plugin:langbot-team/LocalAgent/default',
+ )
+
+ @pytest.mark.asyncio
+ async def test_runner_missing_for_steering_and_run_reports_runner_not_found(self):
+ """A lasting gap still fails: the fallback buys one retry, not recovery."""
+ from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
+ from langbot.pkg.pipeline import entities
+
+ orchestrator = PersistentRunnerGapOrchestrator()
+ mock_ap = MockApplication(orchestrator=orchestrator)
+ mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
+ handler = ChatMessageHandler(mock_ap)
+ query = MockQuery()
+
+ mock_event = MagicMock()
+ mock_event.return_value = MagicMock()
+
+ def make_result(*args, **kwargs):
+ return MagicMock(
+ result_type=kwargs.get('result_type'),
+ user_notice=kwargs.get('user_notice'),
+ )
+
+ with (
+ patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
+ patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
+ ):
+ mock_events_module.PersonNormalMessageReceived = mock_event
+ mock_events_module.GroupNormalMessageReceived = mock_event
+ results = [result async for result in handler.handle(query)]
+
+ assert len(results) == 1
+ assert results[0].result_type == entities.ResultType.INTERRUPT
+ assert 'not found' in results[0].user_notice
+
+ @pytest.mark.asyncio
+ async def test_successful_steering_interrupts_normal_run(self):
+ """A claimed steering message must not start a duplicate runner turn."""
+ from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
+ from langbot.pkg.pipeline import entities
+
+ orchestrator = SteeringClaimedOrchestrator()
+ mock_ap = MockApplication(orchestrator=orchestrator)
+ mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
+ handler = ChatMessageHandler(mock_ap)
+ query = MockQuery()
+
+ mock_event = MagicMock()
+ mock_event.return_value = MagicMock()
+
+ def make_result(*args, **kwargs):
+ return MagicMock(result_type=kwargs.get('result_type'))
+
+ with (
+ patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
+ patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
+ ):
+ mock_events_module.PersonNormalMessageReceived = mock_event
+ mock_events_module.GroupNormalMessageReceived = mock_event
+ results = [result async for result in handler.handle(query)]
+
+ assert len(results) == 1
+ assert results[0].result_type == entities.ResultType.INTERRUPT
+ assert orchestrator.run_called is False
+
+ @pytest.mark.asyncio
+ async def test_unexpected_steering_error_uses_normal_error_handling(self):
+ """Unexpected steering failures must not be silently swallowed."""
+ from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler
+ from langbot.pkg.pipeline import entities
+
+ orchestrator = SteeringUnexpectedErrorOrchestrator()
+ mock_ap = MockApplication(orchestrator=orchestrator)
+ mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False))
+ handler = ChatMessageHandler(mock_ap)
+ query = MockQuery()
+
+ mock_event = MagicMock()
+ mock_event.return_value = MagicMock()
+
+ def make_result(*args, **kwargs):
+ return MagicMock(
+ result_type=kwargs.get('result_type'),
+ user_notice=kwargs.get('user_notice'),
+ )
+
+ with (
+ patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module,
+ patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result),
+ ):
+ mock_events_module.PersonNormalMessageReceived = mock_event
+ mock_events_module.GroupNormalMessageReceived = mock_event
+ results = [result async for result in handler.handle(query)]
+
+ assert len(results) == 1
+ assert results[0].result_type == entities.ResultType.INTERRUPT
+ assert 'Request failed' in results[0].user_notice
+
@pytest.mark.asyncio
async def test_runner_not_authorized_error(self):
"""Handler should catch RunnerNotAuthorizedError and return INTERRUPT."""
diff --git a/tests/unit_tests/agent/test_interaction_store.py b/tests/unit_tests/agent/test_interaction_store.py
index c3dcb6f93..4502bd1cb 100644
--- a/tests/unit_tests/agent/test_interaction_store.py
+++ b/tests/unit_tests/agent/test_interaction_store.py
@@ -10,6 +10,7 @@ import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.interaction_store import (
+ _epoch_to_datetime,
DuplicateInteractionError,
InteractionAlreadySubmittedError,
InteractionExpiredError,
@@ -24,6 +25,21 @@ from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
UTC = datetime.timezone.utc
+@pytest.mark.parametrize('scale', [1, 1000])
+def test_epoch_to_datetime_accepts_seconds_and_milliseconds(scale):
+ assert _epoch_to_datetime(1_790_000_000.125 * scale) == datetime.datetime.fromtimestamp(1_790_000_000.125, UTC)
+
+
+@pytest.mark.parametrize('value', [float('nan'), float('inf'), -float('inf'), 10**400, 'invalid', []])
+def test_epoch_to_datetime_reports_invalid_timestamp(value):
+ with pytest.raises(ValueError, match='expires_at must be a valid epoch timestamp'):
+ _epoch_to_datetime(value)
+
+
+def test_epoch_to_datetime_preserves_absent_expiry():
+ assert _epoch_to_datetime(None) is None
+
+
@pytest.fixture
async def db_engine(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False)
@@ -72,6 +88,14 @@ async def _create(store: InteractionStore, **overrides):
return await store.create_request(**values)
+@pytest.mark.asyncio
+@pytest.mark.parametrize('scale', [1, 1000])
+async def test_create_request_persists_normalized_expiry(store, scale):
+ expires_at = int((datetime.datetime.now(UTC) + datetime.timedelta(minutes=5)).timestamp())
+ record, _token = await _create(store, expires_at=expires_at * scale)
+ assert record['expires_at'] == expires_at
+
+
@pytest.mark.asyncio
async def test_create_request_returns_token_but_persists_only_hash(store, db_engine):
record, token = await _create(store)
diff --git a/tests/unit_tests/agent/test_run_journal.py b/tests/unit_tests/agent/test_run_journal.py
index c229318ca..3b76ffe73 100644
--- a/tests/unit_tests/agent/test_run_journal.py
+++ b/tests/unit_tests/agent/test_run_journal.py
@@ -1,14 +1,79 @@
"""Agent monitoring snapshots retain useful input without duplicating binary payloads."""
+import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.agent.runner.run_journal import AgentRunJournal
+from langbot.pkg.agent.runner import event_log_store
+from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.storage.media import MediaCache
+@pytest.mark.parametrize('scale', [1, 1000, 1_000_000])
+@pytest.mark.parametrize('seconds', [1_790_000_000, 1_790_000_000.125, -1_790_000_000, 10_000_000_000])
+def test_event_time_normalizes_current_epoch_units(scale, seconds):
+ assert AgentRunJournal._event_time_to_utc(seconds * scale) == datetime.datetime.fromtimestamp(
+ seconds, datetime.timezone.utc
+ )
+
+
+@pytest.mark.parametrize(
+ 'value',
+ [None, True, False, 0, '1790000000', [], {}, float('nan'), float('inf'), -float('inf'), 10**400, -(10**400)],
+)
+def test_event_time_rejects_invalid_values_without_raising(value):
+ assert AgentRunJournal._event_time_to_utc(value) is None
+
+
+@pytest.fixture
+def event_log_writer(monkeypatch):
+ append_event = AsyncMock(return_value='event-log-id')
+ monkeypatch.setattr(event_log_store, 'EventLogStore', lambda engine: SimpleNamespace(append_event=append_event))
+ journal = AgentRunJournal(SimpleNamespace(persistence_mgr=SimpleNamespace(get_db_engine=lambda: None)))
+ return journal, append_event
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('scale', [1, 1000, 1_000_000])
+async def test_write_event_log_normalizes_timestamp(event_log_writer, scale):
+ journal, append_event = event_log_writer
+ event = AgentEventEnvelope(
+ event_id='event',
+ event_type='message.received',
+ source='platform',
+ event_time=1_790_000_000 * scale,
+ input={},
+ delivery={'surface': 'platform'},
+ )
+ assert await journal.write_event_log(event, None, 'run', 'runner') == 'event-log-id'
+ assert append_event.call_args.kwargs['event_time'] == datetime.datetime.fromtimestamp(
+ 1_790_000_000, datetime.timezone.utc
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('scale', [1, 1000, 1_000_000])
+async def test_steering_dropped_audit_normalizes_timestamp(event_log_writer, scale):
+ journal, append_event = event_log_writer
+ await journal.write_steering_dropped_audits([{'event': {'event_time': 1_790_000_000 * scale}}], 'run', 'runner')
+ assert append_event.call_args.kwargs['event_type'] == 'steering.dropped'
+ assert append_event.call_args.kwargs['event_time'] == datetime.datetime.fromtimestamp(
+ 1_790_000_000, datetime.timezone.utc
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('value', [None, 0, True, 'invalid', float('inf'), 10**400])
+async def test_invalid_steering_time_does_not_prevent_audit(event_log_writer, value):
+ journal, append_event = event_log_writer
+ await journal.write_steering_dropped_audits([{'event': {'event_time': value}}], 'run', 'runner')
+ append_event.assert_awaited_once()
+ assert append_event.call_args.kwargs['event_time'] is None
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize('event_type', ['message.received', 'group.member_joined'])
async def test_agent_run_snapshot_records_identity_and_redacts_inline_attachments(event_type):
diff --git a/tests/unit_tests/agent/test_run_ledger_store.py b/tests/unit_tests/agent/test_run_ledger_store.py
index 04d41a26a..3f3b2b601 100644
--- a/tests/unit_tests/agent/test_run_ledger_store.py
+++ b/tests/unit_tests/agent/test_run_ledger_store.py
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
-from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore
+from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore, _epoch_to_datetime
from langbot.pkg.entity.persistence.agent_run import AgentRun
from langbot.pkg.entity.persistence.base import Base
@@ -20,6 +20,18 @@ from langbot.pkg.entity.persistence.base import Base
UTC = datetime.timezone.utc
+@pytest.mark.parametrize('scale', [1, 1000])
+def test_epoch_to_datetime_accepts_seconds_and_milliseconds(scale):
+ assert _epoch_to_datetime(1_790_000_000.125 * scale) == datetime.datetime.fromtimestamp(
+ 1_790_000_000.125, UTC
+ ).replace(tzinfo=None)
+
+
+@pytest.mark.parametrize('value', [None, float('nan'), float('inf'), -float('inf'), 10**400, 'invalid', []])
+def test_epoch_to_datetime_ignores_invalid_timestamp(value):
+ assert _epoch_to_datetime(value) is None
+
+
@pytest.fixture
async def db_engine(tmp_path):
db_path = tmp_path / 'run_ledger_store.db'
@@ -38,6 +50,19 @@ def store(db_engine):
return RunLedgerStore(db_engine)
+@pytest.mark.asyncio
+@pytest.mark.parametrize('scale', [1, 1000])
+async def test_create_run_persists_normalized_deadline(store, scale):
+ record = await store.create_run(
+ run_id='run-timestamp',
+ event_id='event',
+ binding_id='binding',
+ runner_id='runner',
+ deadline_at=1_790_000_000 * scale,
+ )
+ assert record['deadline_at'] == 1_790_000_000
+
+
@pytest.fixture
async def legacy_run(store, db_engine):
await store.create_run(
diff --git a/tests/unit_tests/api/service/test_session_context_reset.py b/tests/unit_tests/api/service/test_session_context_reset.py
index 43effa679..70989432f 100644
--- a/tests/unit_tests/api/service/test_session_context_reset.py
+++ b/tests/unit_tests/api/service/test_session_context_reset.py
@@ -154,3 +154,44 @@ async def test_reset_starts_new_external_runner_conversation(context_service):
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
+
+
+@pytest.mark.asyncio
+async def test_signed_http_reset_uses_normal_adapter_wiring_and_clears_persisted_history(context_service):
+ import quart
+
+ from langbot.pkg.api.http.context import ExecutionContext
+ from langbot.pkg.platform.logger import EventLogger
+ from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
+ from langbot.pkg.platform.sources import http_bot_signing as signing
+
+ service, engine = context_service
+ app = SimpleNamespace(monitoring_service=service)
+ context = ExecutionContext(instance_uuid='instance', workspace_uuid='w', placement_generation=1, bot_uuid='b')
+ adapter = HttpBotAdapter(
+ config={'inbound_secret': 'test-secret', 'signature_required': True},
+ logger=EventLogger('test', app, context, 'b'),
+ )
+ # Match BotManager's injection path; no synthetic attributes or model_construct.
+ assert hasattr(adapter, 'ap')
+ adapter.ap = app
+ adapter.set_bot_uuid('b')
+ store = TranscriptStore(engine)
+ for conversation in ('person_u', 'person_peer'):
+ await store.append_transcript(None, 'event', conversation, 'user', bot_id='b', workspace_id='w', content='old')
+ web = quart.Quart(__name__)
+ body = json.dumps({'session_id': 'u', 'session_type': 'person'}).encode()
+ timestamp, signature = signing.sign('test-secret', body)
+ async with web.test_request_context(
+ '/bots/b/reset',
+ method='POST',
+ data=body,
+ headers={signing.HEADER_TIMESTAMP: timestamp, signing.HEADER_SIGNATURE: signature},
+ ):
+ response, status = await adapter.handle_unified_webhook('b', 'reset', quart.request)
+ assert status == 200
+ assert (await response.get_json())['data'] == {'session_id': 'u', 'removed': True}
+ assert not (await store.page_transcript('person_u', bot_id='b', workspace_id='w'))[0]
+ assert (await store.page_transcript('person_peer', bot_id='b', workspace_id='w'))[0]
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2
diff --git a/tests/unit_tests/pipeline/test_bansess.py b/tests/unit_tests/pipeline/test_bansess.py
index 394fb8bc1..8a369e10e 100644
--- a/tests/unit_tests/pipeline/test_bansess.py
+++ b/tests/unit_tests/pipeline/test_bansess.py
@@ -136,3 +136,27 @@ async def test_user_id_wildcard(mock_app, sample_query):
result = await stage.process(sample_query, 'BanSessionCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('mode', ['whitelist', 'blacklist'])
+@pytest.mark.parametrize('launcher_type', [provider_session.LauncherTypes.PERSON, provider_session.LauncherTypes.GROUP])
+@pytest.mark.parametrize('id_type', [int, str])
+@pytest.mark.parametrize('matching_field', ['sender_id', 'launcher_id', None])
+async def test_user_id_wildcard_normalizes_ids(mock_app, sample_query, mode, launcher_type, id_type, matching_field):
+ """Wildcard IDs match numeric platform IDs without allowing unrelated users."""
+ bansess, entities = get_modules()
+ sample_query.launcher_type = launcher_type
+ sample_query.launcher_id = id_type(11111)
+ sample_query.sender_id = id_type(22222)
+ if matching_field is not None:
+ setattr(sample_query, matching_field, id_type(67890))
+ sample_query.pipeline_config = {'trigger': {'access-control': {'mode': mode, mode: ['*_67890']}}}
+
+ stage = bansess.BanSessionCheckStage(mock_app)
+ await stage.initialize(sample_query.pipeline_config)
+ result = await stage.process(sample_query, 'BanSessionCheckStage')
+
+ should_continue = (matching_field is not None) if mode == 'whitelist' else (matching_field is None)
+ expected = entities.ResultType.CONTINUE if should_continue else entities.ResultType.INTERRUPT
+ assert result.result_type == expected
diff --git a/tests/unit_tests/platform/test_http_bot_tenancy.py b/tests/unit_tests/platform/test_http_bot_tenancy.py
index 8e19d143c..35fb2ccbe 100644
--- a/tests/unit_tests/platform/test_http_bot_tenancy.py
+++ b/tests/unit_tests/platform/test_http_bot_tenancy.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
+from unittest.mock import AsyncMock
import pytest
@@ -11,12 +12,6 @@ from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
from langbot.pkg.platform.sources import http_bot as http_bot_module
-def _session(key):
- session = SimpleNamespace()
- session._langbot_session_key = key
- return session
-
-
def _adapter(app, execution_context) -> HttpBotAdapter:
adapter = HttpBotAdapter.model_construct(
config={'signature_required': False},
@@ -27,38 +22,28 @@ def _adapter(app, execution_context) -> HttpBotAdapter:
sync_waiters={},
inbound_tasks=set(),
)
- object.__setattr__(adapter, 'ap', app)
+ assert hasattr(adapter, 'ap') # BotManager only injects declared application fields.
+ adapter.ap = app
return adapter
@pytest.mark.asyncio
-async def test_http_bot_reset_removes_only_exact_execution_scope():
+@pytest.mark.parametrize('launcher_type', ['person', 'group'])
+async def test_http_bot_reset_uses_trusted_scope_and_persistent_context_service(launcher_type):
context = ExecutionContext(
- instance_uuid='instance-a',
- workspace_uuid='workspace-a',
- placement_generation=3,
- bot_uuid='bot-a',
+ instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=3, bot_uuid='bot-a'
)
- target_key = ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'shared-session')
- retained_keys = [
- ('instance-b', 'workspace-a', 3, 'bot-a', 'person', 'shared-session'),
- ('instance-a', 'workspace-b', 3, 'bot-a', 'person', 'shared-session'),
- ('instance-a', 'workspace-a', 4, 'bot-a', 'person', 'shared-session'),
- ('instance-a', 'workspace-a', 3, 'bot-b', 'person', 'shared-session'),
- ('instance-a', 'workspace-a', 3, 'bot-a', 'group', 'shared-session'),
- ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'other-session'),
- ]
- sessions = [_session(target_key), *[_session(key) for key in retained_keys], SimpleNamespace()]
- app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=sessions))
+ reset = AsyncMock(return_value={'reset': True})
+ app = SimpleNamespace(monitoring_service=SimpleNamespace(reset_session_context=reset))
adapter = _adapter(app, context)
- removed = await adapter._reset_session('person', 'shared-session')
-
- assert removed is True
- assert [getattr(session, '_langbot_session_key', None) for session in app.sess_mgr.session_list] == [
- *retained_keys,
- None,
- ]
+ assert await adapter._reset_session(launcher_type, 'shared-session') is True
+ reset.assert_awaited_once_with(context, 'bot-a', f'{launcher_type}_shared-session')
+ reset.side_effect = LookupError('Session not found')
+ assert await adapter._reset_session(launcher_type, 'missing') is False
+ reset.side_effect = RuntimeError('Session is processing a task')
+ with pytest.raises(RuntimeError, match='processing'):
+ await adapter._reset_session(launcher_type, 'busy')
@pytest.mark.asyncio
diff --git a/tests/unit_tests/platform/test_qqofficial_full_group_forms.py b/tests/unit_tests/platform/test_qqofficial_full_group_forms.py
new file mode 100644
index 000000000..6b9af4d29
--- /dev/null
+++ b/tests/unit_tests/platform/test_qqofficial_full_group_forms.py
@@ -0,0 +1,36 @@
+"""QQ group form replies preserve their native target and passive-reply anchor."""
+
+import pytest
+
+import langbot.pkg.core.app # noqa: F401 -- initialize legacy platform registration
+from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
+from tests.unit_tests.platform.test_qqofficial_full_group_messages import group_payload, receive_message
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('transport', ['gateway', 'webhook'])
+@pytest.mark.parametrize('event_type', ['GROUP_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE'])
+async def test_group_form_reply_preserves_buttons_and_passive_reply_target(monkeypatch, transport, event_type):
+ received, outbound = await receive_message(
+ monkeypatch,
+ QQOfficialAdapter,
+ transport,
+ event_type,
+ group_payload(),
+ form_data={
+ 'node_title': 'Choose an option',
+ 'form_content': 'Confirm this request',
+ 'actions': [{'id': 'confirm', 'title': 'Confirm'}],
+ },
+ )
+
+ assert len(received) == 1
+ assert len(outbound) == 1
+ path, payload = outbound[0]
+ assert path == '/v2/groups/group-one/messages'
+ assert payload['msg_type'] == 2
+ assert payload['msg_id'] == 'message-one'
+ assert payload['markdown']['content'] == '### Choose an option\n\nConfirm this request'
+ button = payload['keyboard']['content']['rows'][0]['buttons'][0]
+ assert button['render_data']['label'] == 'Confirm'
+ assert button['action']['data'] == 'confirm'
diff --git a/tests/unit_tests/platform/test_qqofficial_full_group_messages.py b/tests/unit_tests/platform/test_qqofficial_full_group_messages.py
new file mode 100644
index 000000000..8d5bb3751
--- /dev/null
+++ b/tests/unit_tests/platform/test_qqofficial_full_group_messages.py
@@ -0,0 +1,232 @@
+"""Full-group gateway messages retain group identity and passive replies."""
+
+import base64
+import json
+import time
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import httpx
+import pytest
+from quart import Quart, request
+
+import langbot.pkg.core.app # noqa: F401 -- initialize legacy platform registration
+from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
+from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter as LegacyQQOfficialAdapter
+from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
+from langbot_plugin.api.entities.builtin.platform import entities, events, message
+from langbot.pkg.utils import httpclient
+
+
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.parametrize('adapter_class', [QQOfficialAdapter, LegacyQQOfficialAdapter]),
+ pytest.mark.parametrize('transport', ['gateway', 'webhook']),
+]
+PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=')
+
+
+def group_payload(content='hello'):
+ return {
+ 'id': 'message-one',
+ 'group_openid': 'group-one',
+ 'timestamp': '2026-10-24T12:00:00+0800',
+ 'content': content,
+ 'author': {'member_openid': 'member-one'},
+ 'attachments': [],
+ }
+
+
+async def receive_message(monkeypatch, adapter_class, transport, event_type, payload, *, form_data=None):
+ received, outbound = [], []
+ adapter = adapter_class(
+ {'appid': 'fixture-app', 'secret': 'fixture-secret', 'token': '', 'enable-webhook': transport == 'webhook'},
+ Mock(spec=AbstractEventLogger),
+ )
+ adapter.bot.access_token = 'fixture-token'
+ adapter.bot.access_token_expiry_time = time.time() + 3600
+
+ async def gateway(on_event, on_ready, on_error):
+ await on_event(event_type, payload)
+
+ monkeypatch.setattr(adapter.bot, 'connect_gateway_loop', gateway)
+ real_client = httpx.AsyncClient
+
+ async def respond(request):
+ outbound.append((request.url.path, json.loads(request.content)))
+ return httpx.Response(200, json={'id': 'reply-one'}, request=request)
+
+ def client(*args, **kwargs):
+ return real_client(*args, **kwargs, transport=httpx.MockTransport(respond))
+
+ monkeypatch.setattr(httpx, 'AsyncClient', client)
+
+ async def listener(event, active_adapter):
+ received.append(event)
+ reply = message.MessageChain([message.Plain(text='fixture reply')])
+ if form_data is None:
+ await active_adapter.reply_message(event, reply)
+ else:
+ await active_adapter.reply_message_chunk(event, SimpleNamespace(_form_data=form_data), reply, is_final=True)
+
+ listener_type = events.MessageReceivedEvent
+ if adapter_class is LegacyQQOfficialAdapter:
+ listener_type = events.FriendMessage if event_type == 'C2C_MESSAGE_CREATE' else events.GroupMessage
+ adapter.register_listener(listener_type, listener)
+ try:
+ if transport == 'gateway':
+ await adapter.run_async()
+ else:
+ app = Quart(__name__)
+
+ @app.post('/qq')
+ async def callback():
+ return await adapter.handle_unified_webhook('fixture-bot', '', request)
+
+ response = await app.test_client().post('/qq', json={'op': 0, 't': event_type, 'd': payload})
+ assert response.status_code == 200
+ finally:
+ await adapter.kill()
+ return received, outbound
+
+
+async def test_unmentioned_full_group_message_retains_identity_and_passive_reply(monkeypatch, adapter_class, transport):
+ received, outbound = await receive_message(
+ monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', group_payload()
+ )
+ assert len(received) == 1
+ event = received[0]
+ assert event.sender.id == 'member-one'
+ assert event.group.id == 'group-one'
+ if isinstance(event, events.MessageReceivedEvent):
+ assert event.chat_type == entities.ChatType.GROUP
+ assert event.chat_id == 'group-one'
+ assert event.source_platform_object.t == 'GROUP_MESSAGE_CREATE'
+ assert not any(isinstance(component, message.At) for component in event.message_chain)
+ assert [component.text for component in event.message_chain if isinstance(component, message.Plain)] == ['hello']
+ assert len(outbound) == 1
+ assert outbound[0][0] == '/v2/groups/group-one/messages'
+ assert outbound[0][1]['msg_id'] == 'message-one'
+
+
+@pytest.mark.parametrize(
+ 'key,content,expected',
+ [
+ ('id', '<@bot-one> /help', ' /help'),
+ ('member_openid', '/help <@!bot-one>', '/help '),
+ ('openid', '<@other-one> <@bot-one> help', '<@other-one> help'),
+ ],
+)
+async def test_native_self_mention_in_full_group_mode_still_marks_bot_and_strips_token(
+ monkeypatch, adapter_class, transport, key, content, expected
+):
+ payload = group_payload(content)
+ payload['mentions'] = [{key: 'bot-one', 'is_you': True}]
+ received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload)
+ assert len(received) == 1
+ chain = received[0].message_chain
+ expected_target = 'fixture-app' if adapter_class is QQOfficialAdapter else 'justbot'
+ assert [component.target for component in chain if isinstance(component, message.At)] == [expected_target]
+ assert [component.text for component in chain if isinstance(component, message.Plain)] == [expected]
+ assert received[0].source_platform_object.t == 'GROUP_MESSAGE_CREATE'
+ assert len(outbound) == 1
+
+
+@pytest.mark.parametrize(
+ 'mentions',
+ [
+ None,
+ {'id': 'bot-one', 'is_you': True},
+ [None, 'bot-one'],
+ [{'id': 'bot-one', 'bot': True}],
+ [{'id': 'bot-one', 'is_you': False}],
+ [{'id': 'bot-one', 'is_you': 1}],
+ [{'id': 'bot-one', 'is_you': 'true'}],
+ [{'id': 'other-one', 'is_you': True}],
+ [{'id': '<@all>', 'is_you': True}],
+ [{'id': 'x' * 129, 'is_you': True}],
+ ],
+)
+async def test_other_or_unverified_mentions_do_not_authorize_bot_response(
+ monkeypatch, adapter_class, transport, mentions
+):
+ content = '<@bot-one> @fixture-app /help'
+ payload = group_payload(content)
+ payload.update(mentions=mentions, is_at_bot=True, bot_account_id='untrusted')
+ received, _ = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload)
+ assert len(received) == 1
+ assert not any(isinstance(component, message.At) for component in received[0].message_chain)
+ assert [component.text for component in received[0].message_chain if isinstance(component, message.Plain)] == [
+ content
+ ]
+
+
+async def test_group_image_without_mention_remains_an_image_without_at(monkeypatch, adapter_class, transport):
+ class Download:
+ headers = {'Content-Length': str(len(PNG))}
+
+ def raise_for_status(self):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ pass
+
+ async def iter_chunked(self, size):
+ yield PNG
+
+ @property
+ def content(self):
+ return self
+
+ downloads = []
+
+ def get(url):
+ downloads.append(url)
+ return Download()
+
+ monkeypatch.setattr(httpclient, 'get_session', lambda: SimpleNamespace(get=get))
+ payload = group_payload('')
+ payload['attachments'] = [{'url': 'https://images.example/image.png', 'content_type': 'image/png'}]
+ received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload)
+ assert len(received) == 1
+ assert not any(isinstance(component, message.At) for component in received[0].message_chain)
+ images = [component for component in received[0].message_chain if isinstance(component, message.Image)]
+ assert len(images) == 1 and images[0].url == 'https://images.example/image.png'
+ assert base64.b64decode(images[0].base64.split(',')[-1]) == PNG
+ assert downloads == ['https://images.example/image.png']
+ assert len(outbound) == 1 and outbound[0][1]['msg_id'] == 'message-one'
+
+
+@pytest.mark.parametrize('author', [{'openid': 'old-member'}, {'member_openid': 'member-one', 'openid': 'old-member'}])
+async def test_member_id_supports_legacy_alias_and_prefers_canonical_field(
+ monkeypatch, adapter_class, transport, author
+):
+ payload = group_payload()
+ payload['author'] = author
+ received, _ = await receive_message(monkeypatch, adapter_class, transport, 'GROUP_MESSAGE_CREATE', payload)
+ assert received[0].sender.id == author.get('member_openid', 'old-member')
+
+
+async def test_existing_at_only_mode_still_marks_the_bot(monkeypatch, adapter_class, transport):
+ received, outbound = await receive_message(
+ monkeypatch, adapter_class, transport, 'GROUP_AT_MESSAGE_CREATE', group_payload('/help')
+ )
+ expected_target = 'fixture-app' if adapter_class is QQOfficialAdapter else 'justbot'
+ assert [component.target for component in received[0].message_chain if isinstance(component, message.At)] == [
+ expected_target
+ ]
+ assert len(outbound) == 1 and outbound[0][1]['msg_id'] == 'message-one'
+
+
+async def test_private_message_still_uses_its_original_reply_target(monkeypatch, adapter_class, transport):
+ payload = group_payload()
+ payload['author'] = {'user_openid': 'private-one'}
+ payload.pop('group_openid')
+ received, outbound = await receive_message(monkeypatch, adapter_class, transport, 'C2C_MESSAGE_CREATE', payload)
+ assert received[0].sender.id == 'private-one'
+ assert not any(isinstance(component, message.At) for component in received[0].message_chain)
+ assert outbound[0][0] == '/v2/users/private-one/messages'
+ assert outbound[0][1]['msg_id'] == 'message-one'
diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py
index 40d738936..8aa23e004 100644
--- a/tests/unit_tests/plugin/test_connector_ping.py
+++ b/tests/unit_tests/plugin/test_connector_ping.py
@@ -460,3 +460,171 @@ async def test_explicit_cloud_binding_is_revalidated_against_projection():
expected_generation=7,
)
connector._synchronize_workspace.assert_awaited_once_with(configured)
+
+
+def test_runtime_debug_port_from_url_defaults_and_parses():
+ assert PluginRuntimeConnector._runtime_debug_port_from_url('') == 5401
+ assert PluginRuntimeConnector._runtime_debug_port_from_url('ws://localhost:5411/plugin/debug/ws') == 5411
+ assert PluginRuntimeConnector._runtime_debug_port_from_url('localhost:5412/plugin/debug/ws') == 5412
+ assert PluginRuntimeConnector._runtime_control_port_from_url('ws://langbot_plugin_runtime:5500/control/ws') == 5500
+ assert PluginRuntimeConnector._runtime_control_port_from_url('') == 5400
+
+
+def test_managed_runtime_ports_follow_plugin_config_urls():
+ connector = make_connector()
+ connector.ap.instance_config.data['plugin']['runtime_ws_url'] = 'ws://127.0.0.1:5500/control/ws'
+ connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://127.0.0.1:5411/plugin/debug/ws'
+
+ assert connector._managed_runtime_ports() == (5500, 5411)
+
+
+def test_managed_runtime_ports_default_when_urls_empty():
+ connector = make_connector()
+ connector.ap.instance_config.data['plugin']['runtime_ws_url'] = ''
+ connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = ''
+
+ assert connector._managed_runtime_ports() == (5400, 5401)
+
+
+@pytest.mark.asyncio
+async def test_stdio_runtime_passes_debug_port_from_display_url(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ connector = make_connector()
+ connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://localhost:5411/plugin/debug/ws'
+ connector._prepare_connected_runtime = AsyncMock()
+ created = {}
+ monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
+
+ class FakeRuntimeHandler:
+ def __init__(self, connection, disconnect_callback, ap):
+ self.release = asyncio.Event()
+
+ async def ping(self):
+ return None
+
+ async def set_runtime_config(self, **kwargs):
+ return None
+
+ async def run(self):
+ await self.release.wait()
+
+ async def close(self):
+ self.release.set()
+
+ class FakeController:
+ def __init__(self, **kwargs):
+ created.update(kwargs)
+
+ async def run(self, callback):
+ await callback(object())
+
+ async def close(self):
+ return None
+
+ monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
+ monkeypatch.setattr(
+ connector_module.platform,
+ 'use_websocket_to_connect_plugin_runtime',
+ lambda: False,
+ )
+ monkeypatch.setattr(
+ connector_module.handler,
+ 'RuntimeConnectionHandler',
+ FakeRuntimeHandler,
+ )
+ monkeypatch.setattr(
+ connector_module.stdio_client_controller,
+ 'StdioClientController',
+ FakeController,
+ )
+
+ await connector.initialize()
+
+ assert created['args'] == [
+ '-m',
+ 'langbot_plugin.cli.__init__',
+ 'rt',
+ '-s',
+ '--ws-debug-port',
+ '5411',
+ ]
+ assert connector._connected.is_set()
+ await connector.aclose()
+
+
+@pytest.mark.asyncio
+async def test_windows_runtime_passes_control_and_debug_ports(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ connector = make_connector()
+ connector.ap.instance_config.data['plugin']['runtime_ws_url'] = 'ws://localhost:5500/control/ws'
+ connector.ap.instance_config.data['plugin']['display_plugin_debug_url'] = 'ws://localhost:5411/plugin/debug/ws'
+ connector._prepare_connected_runtime = AsyncMock()
+ start_args: list[object] = []
+ created = {}
+ monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
+
+ class FakeRuntimeHandler:
+ def __init__(self, connection, disconnect_callback, ap):
+ self.release = asyncio.Event()
+
+ async def ping(self):
+ return None
+
+ async def set_runtime_config(self, **kwargs):
+ return None
+
+ async def run(self):
+ await self.release.wait()
+
+ async def close(self):
+ self.release.set()
+
+ class FakeController:
+ def __init__(self, **kwargs):
+ created.update(kwargs)
+
+ async def run(self, callback):
+ await callback(object())
+
+ async def close(self):
+ return None
+
+ async def fake_start(*args, env_overrides=None):
+ start_args.extend(args)
+ start_args.append(env_overrides)
+
+ monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'win32')
+ monkeypatch.setattr(
+ connector_module.platform,
+ 'use_websocket_to_connect_plugin_runtime',
+ lambda: False,
+ )
+ monkeypatch.setattr(
+ connector_module.handler,
+ 'RuntimeConnectionHandler',
+ FakeRuntimeHandler,
+ )
+ monkeypatch.setattr(
+ connector_module.ws_client_controller,
+ 'WebSocketClientController',
+ FakeController,
+ )
+ monkeypatch.setattr(connector, '_start_runtime_subprocess', fake_start)
+
+ await connector.initialize()
+
+ assert start_args[:-1] == [
+ '-m',
+ 'langbot_plugin.cli.__init__',
+ 'rt',
+ '--ws-control-port',
+ '5500',
+ '--ws-debug-port',
+ '5411',
+ ]
+ assert start_args[-1] == {PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: connector._control_token}
+ assert created['ws_url'] == 'ws://localhost:5500/control/ws'
+ assert connector._connected.is_set()
+ await connector.aclose()
diff --git a/tools/README.md b/tools/README.md
index cd4100ff2..be9b3b899 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -502,3 +502,48 @@ Keep this longer test outside the one-minute merge gate while its budgets and
repeatability are being evaluated. The same profile can be selected by the
release-deployment Job, which supplies its ledger path and process IDs; that
fresh-deployment path still needs separate end-to-end validation.
+
+## Business behavior coverage
+
+`regression_contracts.json` declares behavior obligations independently of how
+many requests a workload repeats. It includes explicit unimplemented risks
+(cancellation, restart recovery, credential revocation, Workspace isolation,
+PostgreSQL parity, real adapters and embeddings). This is coverage of the
+registered scope, not a percentage of all product behavior or API endpoints.
+
+Runners record named assertions as they execute. A green legacy request without
+assertion evidence is **incomplete**. An evaluated failing assertion counts as
+covered and failed; a timeout before an assertion does not count. A contract with
+multiple required assertions passes only when all are observed. Repeated
+observations cannot hide an incomplete or failed turn. History contracts require
+a subsequent turn, not merely the initial reply. Database/Box-event inspections
+are reported separately as gray-box evidence.
+
+Reports distinguish `passed`, `failed`, `incomplete`, `skipped`, `not_run` (a test
+exists but did not execute) and `missing_test`. Coverage and pass rate have separate
+denominators. The catalog fingerprint identifies the registered denominator.
+New case names with no contract appear as unclassified, and malformed evidence
+is reported as an error instead of silently accepted.
+
+The source route inventory discovers current static RouterGroup HTTP declarations
+and flags routes without a linked behavior contract. Links describe test intent;
+they are not observed HTTP hits or proof that every method behavior is tested.
+Dynamic routes, WebSockets and platform-dispatched routes are outside this static
+inventory. Full source locations and any unresolved declarations are retained
+in `coverage.json`; new declarations appear without editing the case list.
+
+```sh
+python tools/regression_coverage.py --run .qa/smoke/suite \
+ --run .qa/endurance/suite --output .qa/behavior-coverage
+```
+
+`coverage.json` and `coverage.md` are written by the Suite. CI consumers can
+publish these files as artifacts. Uncovered contracts do not add a merge gate. A missing or corrupt evidence
+file is a reporting failure; the report remains available for inspection.
+
+Pass `--http-log /path/to/core.log` to include observed methods, declared route
+templates and status counts from Hypercorn access records. Query strings and
+resource identifiers are not retained in this report. Unmatched requests are
+counted by method/status; webhook sub-paths are not separate declared routes.
+Missing logs or logs without recognizable access records are evidence errors.
+HTTP hits remain separate from assertion coverage.
diff --git a/tools/mock-backends.md b/tools/mock-backends.md
new file mode 100644
index 000000000..b62ee1f70
--- /dev/null
+++ b/tools/mock-backends.md
@@ -0,0 +1,102 @@
+# Deterministic model faults
+
+Run the test backend with Python 3.11 or newer:
+
+```sh
+python tools/mock_llm.py --port 18080 --record /tmp/mock-requests.jsonl
+```
+
+Point the LangBot OpenAI-compatible provider at `http://127.0.0.1:18080/v1`
+and choose a model below. The default `mock-chat`, `mock-embedding`, tool and
+smoke models retain their existing behavior. `GET /v1/models` lists all models.
+
+| Model | Injected behavior |
+| --- | --- |
+| `mock-stream-disconnect` | Send two SSE chunks, then close an incomplete HTTP chunked response. Requires streaming. |
+| `mock-stream-stall` | Pause for two seconds after the second SSE chunk, then finish. Requires streaming. |
+| `mock-recover-503` | Return 503 on the first request in a scope, then `LANGBOT_RECOVERY_OK`. |
+| `mock-recover-stream-disconnect` | Disconnect the first stream in a scope; later streams finish with `LANGBOT_STREAM_RECOVERY_OK`. |
+| `mock-embedding-429`, `mock-embedding-503` | Return the named HTTP error. |
+| `mock-embedding-timeout` | Wait two seconds before responding. Set the client timeout below two seconds to observe a timeout. |
+| `mock-embedding-wrong-count` | Omit the final input's vector, including an empty result for one input. |
+| `mock-embedding-wrong-dimension` | Return one fewer dimension, or two dimensions when one was requested. Supports float and base64 encoding. |
+| `mock-embedding-recover-503` | Return 503 on the first embedding request in a scope, then normal vectors. |
+
+The daily tool recovery models validate returned tool results before advancing.
+`mock-daily-empty-rag-chat` rejects either configured knowledge sentinel anywhere
+in the model messages. Token-hash embeddings exercise protocol and retrieval
+plumbing; they do not evaluate semantic model quality.
+
+## Isolate recovery attempts
+
+Assign each run/case/conversation a unique scope, such as `run17.rag.group3`.
+Set this in the LangBot model's advanced parameters:
+
+```json
+{"extra_body": {"mock_scope": "run17.rag.group3"}}
+```
+
+The Core LiteLLM requester forwards this as top-level `mock_scope` in both chat
+and embedding requests. A model-level value is shared by every conversation
+using that model: use a separate model configuration per scope or use a marker
+`LB_MOCK_SCOPE=run17.rag.group3` in the current user message. Markers in older
+chat history are ignored. Embedding input strings also support markers, but
+they change the hashed vectors; prefer `extra_body` for embedding tests.
+An explicit `mock_scope` overrides markers. IDs accept 1–128 ASCII letters,
+digits and `_.:-`. Scopes are test correlation IDs, not tenant authorization.
+
+To exercise the same contract directly, run this request twice; the first
+returns 503 and the second returns the recovery message:
+
+```sh
+curl -i http://127.0.0.1:18080/v1/chat/completions \
+ -H 'Content-Type: application/json' \
+ -d '{"model":"mock-recover-503","mock_scope":"run17.direct","messages":[{"role":"user","content":"hello"}]}'
+```
+
+Counters are isolated by endpoint, model name and scope. Each validated request,
+including an SDK retry, consumes an attempt; tool steps still follow the actual
+tool history. SDK retries can consume the failure and recovery within one
+product call: the current Core embedding path retries a 503 even when model
+advanced parameters set `max_retries: 0` and `num_retries: 0`. Use the permanent
+error models to assert a visible error, and inspect recorded attempts for
+recovery scenarios. A request rejected by prompt/tool/input validation does not
+consume an attempt.
+
+After draining a scenario's requests, release all its counters:
+
+```sh
+curl -X DELETE http://127.0.0.1:18080/_mock/scopes/run17.rag.group3
+```
+
+The response reports the number released. Reusing a released scope restarts its
+fault sequence. Idle counters expire after one hour; at 2,048 retained counters,
+new scopes receive 400 until counters are released or expire. Existing scopes
+continue working. The backend is unauthenticated and defaults to loopback;
+use it only in the test environment.
+
+## Configure and verify a fault
+
+Custom `--scenarios` JSON can set `status`, `delay_seconds`,
+`chunk_delay_seconds`, `disconnect_after_chunks`, or the pair
+`stall_after_chunks` / `stall_seconds`. Delays are bounded to 0–60 seconds;
+the first role chunk counts toward stream positions. Add `recover_after: N`
+to apply the controls only to the first N requests per scope (1–100).
+Embedding scenarios use `kind: "embedding"` and optionally
+`embedding_fault: "count"` or `"dimension"`.
+
+Each completed handler appends the request, scope, attempt (for recovery), HTTP
+status, actual `faults`, and `response_complete` to the JSONL record. Delays
+include measured elapsed time. A deliberate stream cut records its chunk count
+and `injected_disconnect`; socket errors caused by the client record
+`disconnected`. `response` contains the planned chat message or embedding
+payload; `response_complete` reports whether the server finished writing it,
+not whether the client received it. Check the client result too. A configured
+stream position that was never reached produces no injection event and cannot
+count as a successful fault test. Records contain request bodies; use test data.
+
+Run the HTTP tests without installing LangBot dependencies:
+
+```sh
+python -m unittest discover -s tools -p test_mock_llm.py -v
+```
diff --git a/tools/mock_llm.py b/tools/mock_llm.py
index 6a97cb3fa..29eba3668 100644
--- a/tools/mock_llm.py
+++ b/tools/mock_llm.py
@@ -9,6 +9,7 @@ import json
import math
from pathlib import Path
import re
+import socket
import struct
import threading
import time
@@ -18,6 +19,36 @@ import uuid
MAX_BODY_BYTES = 1024 * 1024
USAGE = {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15}
GROUP_TURN = re.compile(r'LB_GROUP=(\S+) LB_TURN=(\d+) LB_TOKEN=([a-f0-9]+) LB_PREVIOUS=(none|[a-f0-9]+)')
+SCOPE_MARKER = re.compile(r'\bLB_MOCK_SCOPE=([^\s]+)')
+SCOPE_ID = re.compile(r'[A-Za-z0-9_.:-]{1,128}')
+MAX_FAULT_SCOPES = 2048
+FAULT_SCOPE_TTL_SECONDS = 3600
+
+
+class StreamDisconnected(Exception):
+ """The mock deliberately closed an unfinished HTTP chunked response."""
+
+
+def fault_scope(body, embedding):
+ if 'mock_scope' in body:
+ scope = body['mock_scope']
+ else:
+ if not embedding:
+ text = next(
+ (message.get('content', '') for message in reversed(body['messages']) if message.get('role') == 'user'),
+ '',
+ )
+ else:
+ text = body.get('input', '')
+ if isinstance(text, list):
+ text = '\n'.join(str(item.get('text', '') if isinstance(item, dict) else item) for item in text)
+ scopes = set(SCOPE_MARKER.findall(str(text)))
+ if len(scopes) > 1:
+ raise ValueError('request contains conflicting LB_MOCK_SCOPE markers')
+ scope = next(iter(scopes), None)
+ if scope is not None and (not isinstance(scope, str) or not SCOPE_ID.fullmatch(scope)):
+ raise ValueError('mock_scope must contain 1..128 letters, digits, or _.:-')
+ return scope
def group_ack(messages, start):
@@ -116,6 +147,8 @@ def select_message(body, scenario):
if expected_prompt := step.get('expect_prompt'):
if expected_prompt not in json.dumps(messages, ensure_ascii=False):
raise ValueError(f'model input must contain {expected_prompt!r}')
+ if any(value in json.dumps(messages, ensure_ascii=False) for value in step.get('forbid_prompt', [])):
+ raise ValueError('model input contains forbidden knowledge content')
expected = step.get('expect_tool_output')
if expected is not None:
results = turn[calls[-1] + 1 :] if calls else []
@@ -151,10 +184,34 @@ class MockServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address, scenarios, record_path=None):
+ validate_scenarios(scenarios)
super().__init__(address, Handler)
- self.scenarios = scenarios
+ self.scenarios = {'mock-embedding': {'kind': 'embedding'}, **scenarios}
self.record_path = record_path
self.record_lock = threading.Lock()
+ self.fault_lock = threading.Lock()
+ self.fault_scopes = {}
+
+ def fault_controls(self, body, scenario, entry):
+ scope = fault_scope(body, entry['path'] == '/v1/embeddings')
+ entry['scope'] = scope
+ if 'recover_after' not in scenario:
+ return scenario
+ if scope is None:
+ raise ValueError('recovery scenarios require mock_scope or LB_MOCK_SCOPE')
+ key = (entry['path'], body['model'], scope)
+ now = time.monotonic()
+ with self.fault_lock:
+ for expired in [
+ key for key, (_, touched) in self.fault_scopes.items() if now - touched >= FAULT_SCOPE_TTL_SECONDS
+ ]:
+ del self.fault_scopes[expired]
+ if key not in self.fault_scopes and len(self.fault_scopes) >= MAX_FAULT_SCOPES:
+ raise ValueError('mock fault scope limit reached; release completed scopes first')
+ attempt = self.fault_scopes.get(key, (0, now))[0] + 1
+ self.fault_scopes[key] = (attempt, now)
+ entry.update(attempt=attempt, fault_active=attempt <= scenario['recover_after'])
+ return scenario if entry['fault_active'] else {}
def record(self, entry):
if self.record_path:
@@ -192,7 +249,7 @@ class Handler(BaseHTTPRequestHandler):
'created': 0,
'owned_by': 'mock',
}
- for name in [*self.server.scenarios, 'mock-embedding']
+ for name in self.server.scenarios
],
},
)
@@ -207,8 +264,33 @@ class Handler(BaseHTTPRequestHandler):
},
)
+ def do_DELETE(self):
+ prefix = '/_mock/scopes/'
+ scope = self.path.removeprefix(prefix)
+ if not self.path.startswith(prefix) or not SCOPE_ID.fullmatch(scope):
+ self.json_response(400, {'error': {'message': 'use DELETE /_mock/scopes/'}})
+ return
+ with self.server.fault_lock:
+ keys = [key for key in self.server.fault_scopes if key[2] == scope]
+ for key in keys:
+ del self.server.fault_scopes[key]
+ self.json_response(200, {'scope': scope, 'released': len(keys)})
+
+ def delay(self, seconds, kind, entry):
+ if seconds:
+ start = time.monotonic()
+ time.sleep(seconds)
+ entry['faults'].append({'kind': kind, 'seconds': seconds, 'elapsed_seconds': time.monotonic() - start})
+
def do_POST(self):
- entry = {'request_id': uuid.uuid4().hex, 'time': time.time(), 'path': self.path, 'status': 400}
+ entry = {
+ 'request_id': uuid.uuid4().hex,
+ 'time': time.time(),
+ 'path': self.path,
+ 'status': 400,
+ 'faults': [],
+ 'response_complete': False,
+ }
try:
if self.path not in ('/v1/chat/completions', '/v1/embeddings'):
entry['status'] = 404
@@ -221,6 +303,7 @@ class Handler(BaseHTTPRequestHandler):
}
},
)
+ entry['response_complete'] = True
return
self.connection.settimeout(10)
length = int(self.headers.get('Content-Length', 0))
@@ -230,31 +313,43 @@ class Handler(BaseHTTPRequestHandler):
if not isinstance(body, dict) or not isinstance(body.get('model'), str):
raise ValueError('request must be an object with a model name')
entry['request'] = body
- if self.path == '/v1/embeddings':
- if body['model'] != 'mock-embedding':
- raise ValueError('embedding model must be mock-embedding')
- payload = mock_embeddings(body)
- entry.update(status=200, response=payload)
- self.json_response(200, payload)
- return
scenario = self.server.scenarios.get(body['model'])
if scenario is None:
raise ValueError('unknown model; use GET /v1/models')
- message = select_message(body, scenario)
- time.sleep(scenario.get('delay_seconds', 0))
- status = scenario.get('status', 200)
+ embedding = self.path == '/v1/embeddings'
+ if embedding != (scenario.get('kind') == 'embedding'):
+ raise ValueError('model does not support this endpoint')
+ if embedding:
+ payload = mock_embeddings(body)
+ else:
+ message = select_message(body, scenario)
+ if not body.get('stream') and (
+ 'disconnect_after_chunks' in scenario or 'stall_after_chunks' in scenario
+ ):
+ raise ValueError('this fault scenario requires stream=true')
+ controls = self.server.fault_controls(body, scenario, entry)
+ self.delay(controls.get('delay_seconds', 0), 'delay', entry)
+ status = controls.get('status', 200)
entry['status'] = status
if status != 200:
- self.json_response(
- status,
- {
- 'error': {
- 'message': 'injected mock failure',
- 'type': 'api_error',
- 'code': str(status),
- }
- },
- )
+ entry['faults'].append({'kind': 'http_error', 'status': status})
+ entry['response'] = {
+ 'error': {'message': 'injected mock failure', 'type': 'api_error', 'code': str(status)}
+ }
+ self.json_response(status, entry['response'])
+ entry['response_complete'] = True
+ return
+ if embedding:
+ if fault := controls.get('embedding_fault'):
+ if fault == 'count':
+ payload['data'].pop()
+ else:
+ dimensions = body.get('dimensions', 384)
+ payload = mock_embeddings({**body, 'dimensions': dimensions - 1 if dimensions > 1 else 2})
+ entry['faults'].append({'kind': 'embedding_' + fault})
+ entry['response'] = payload
+ self.json_response(200, payload)
+ entry['response_complete'] = True
return
entry['response'] = message
base = {
@@ -264,7 +359,7 @@ class Handler(BaseHTTPRequestHandler):
}
finish = 'tool_calls' if message.get('tool_calls') else 'stop'
if body.get('stream'):
- self.stream_response(body, scenario, message, base, finish)
+ self.stream_response(body, controls, message, base, finish, entry)
else:
self.json_response(
200,
@@ -275,29 +370,51 @@ class Handler(BaseHTTPRequestHandler):
'usage': USAGE,
},
)
+ entry['response_complete'] = True
+ except StreamDisconnected:
+ entry['injected_disconnect'] = True
except (ValueError, KeyError, TypeError, TimeoutError) as exc:
entry.update(status=400, error=str(exc))
self.json_response(400, {'error': {'message': str(exc), 'type': 'invalid_request_error'}})
+ entry['response_complete'] = True
except (BrokenPipeError, ConnectionResetError):
entry['disconnected'] = True
finally:
self.server.record(entry)
- def stream_response(self, body, scenario, message, base, finish):
+ def stream_response(self, body, scenario, message, base, finish, entry):
+ chunked = 'disconnect_after_chunks' in scenario
+ if chunked:
+ self.protocol_version = 'HTTP/1.1'
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
+ if chunked:
+ self.send_header('Transfer-Encoding', 'chunked')
self.end_headers()
+ def write(data):
+ if chunked:
+ data = f'{len(data):x}\r\n'.encode() + data + b'\r\n'
+ self.wfile.write(data)
+ self.wfile.flush()
+
def send(delta, reason=None):
payload = {
**base,
'object': 'chat.completion.chunk',
'choices': [{'index': 0, 'delta': delta, 'finish_reason': reason}],
}
- self.wfile.write(('data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n').encode())
- self.wfile.flush()
- time.sleep(scenario.get('chunk_delay_seconds', 0))
+ write(('data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n').encode())
+ entry['stream_chunks'] = entry.get('stream_chunks', 0) + 1
+ if entry['stream_chunks'] == scenario.get('disconnect_after_chunks'):
+ entry['faults'].append({'kind': 'stream_disconnect', 'after_chunks': entry['stream_chunks']})
+ self.close_connection = True
+ self.connection.shutdown(socket.SHUT_RDWR)
+ raise StreamDisconnected
+ if entry['stream_chunks'] == scenario.get('stall_after_chunks'):
+ self.delay(scenario['stall_seconds'], 'stream_stall', entry)
+ self.delay(scenario.get('chunk_delay_seconds', 0), 'chunk_delay', entry)
send({'role': 'assistant'})
content = message.get('content') or ''
@@ -339,9 +456,65 @@ class Handler(BaseHTTPRequestHandler):
'choices': [],
'usage': USAGE,
}
- self.wfile.write(('data: ' + json.dumps(payload) + '\n\n').encode())
- self.wfile.write(b'data: [DONE]\n\n')
- self.wfile.flush()
+ write(('data: ' + json.dumps(payload) + '\n\n').encode())
+ write(b'data: [DONE]\n\n')
+ if chunked:
+ self.wfile.write(b'0\r\n\r\n')
+ self.wfile.flush()
+
+
+def validate_scenarios(scenarios):
+ if not isinstance(scenarios, dict) or not scenarios:
+ raise ValueError('scenarios must be a non-empty object keyed by model name')
+ for name, scenario in scenarios.items():
+ if not isinstance(scenario, dict):
+ raise ValueError(f'{name}: scenario must be an object')
+ if scenario.get('kind', 'chat') not in ('chat', 'embedding'):
+ raise ValueError(f'{name}: kind must be chat or embedding')
+ steps = scenario.get('steps', [])
+ if not isinstance(steps, list) or (not steps and scenario.get('kind') != 'embedding'):
+ raise ValueError(f'{name}: steps must be a non-empty array')
+ if any(not isinstance(step, dict) for step in steps):
+ raise ValueError(f'{name}: each step must be an object')
+ for step in steps:
+ if not isinstance(step.get('content'), str) and not step.get('tool_calls'):
+ raise ValueError(f'{name}: each step needs content or tool_calls')
+ if 'expect_tool_output' in step and not isinstance(step['expect_tool_output'], str):
+ raise ValueError(f'{name}: expect_tool_output must be a string')
+ if 'expect_prompt' in step and not isinstance(step['expect_prompt'], str):
+ raise ValueError(f'{name}: expect_prompt must be a string')
+ if 'forbid_prompt' in step and (
+ not isinstance(step['forbid_prompt'], list)
+ or any(not isinstance(value, str) or not value for value in step['forbid_prompt'])
+ ):
+ raise ValueError(f'{name}: forbid_prompt must contain non-empty strings')
+ if 'tool_calls' in step:
+ calls = step['tool_calls']
+ if not isinstance(calls, list) or any(
+ not isinstance(call, dict)
+ or not isinstance(call.get('name'), str)
+ or not isinstance(call.get('arguments'), dict)
+ for call in calls
+ ):
+ raise ValueError(f'{name}: tool_calls need a name and arguments object')
+ if type(scenario.get('status', 200)) is not int or scenario.get('status', 200) not in (200, 401, 429, 500, 503):
+ raise ValueError(f'{name}: unsupported injected HTTP status')
+ for key in ('delay_seconds', 'chunk_delay_seconds', 'stall_seconds'):
+ value = scenario.get(key, 0)
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 60:
+ raise ValueError(f'{name}: {key} must be between 0 and 60')
+ for key in ('recover_after', 'disconnect_after_chunks', 'stall_after_chunks'):
+ if key in scenario and (type(scenario[key]) is not int or not 1 <= scenario[key] <= 100):
+ raise ValueError(f'{name}: {key} must be an integer in 1..100')
+ if ('stall_after_chunks' in scenario) != ('stall_seconds' in scenario):
+ raise ValueError(f'{name}: stall_after_chunks and stall_seconds must be set together')
+ if scenario.get('embedding_fault') not in (None, 'count', 'dimension'):
+ raise ValueError(f'{name}: embedding_fault must be count or dimension')
+ if scenario.get('kind') == 'embedding':
+ if any(key in scenario for key in ('disconnect_after_chunks', 'stall_after_chunks', 'chunk_delay_seconds')):
+ raise ValueError(f'{name}: embedding scenarios cannot set stream controls')
+ elif 'embedding_fault' in scenario:
+ raise ValueError(f'{name}: embedding_fault requires kind=embedding')
def main():
@@ -360,35 +533,10 @@ def main():
)
args = parser.parse_args()
scenarios = json.loads(args.scenarios.read_text(encoding='utf-8'))
- if not isinstance(scenarios, dict) or not scenarios:
- parser.error('scenarios must be a non-empty object keyed by model name')
- for name, scenario in scenarios.items():
- if not isinstance(scenario, dict) or not isinstance(scenario.get('steps'), list) or not scenario['steps']:
- parser.error(f'{name}: steps must be a non-empty array')
- if any(not isinstance(step, dict) for step in scenario['steps']):
- parser.error(f'{name}: each step must be an object')
- for step in scenario['steps']:
- if not isinstance(step.get('content'), str) and not step.get('tool_calls'):
- parser.error(f'{name}: each step needs content or tool_calls')
- if 'expect_tool_output' in step and not isinstance(step['expect_tool_output'], str):
- parser.error(f'{name}: expect_tool_output must be a string')
- if 'expect_prompt' in step and not isinstance(step['expect_prompt'], str):
- parser.error(f'{name}: expect_prompt must be a string')
- if 'tool_calls' in step:
- calls = step['tool_calls']
- if not isinstance(calls, list) or any(
- not isinstance(call, dict)
- or not isinstance(call.get('name'), str)
- or not isinstance(call.get('arguments'), dict)
- for call in calls
- ):
- parser.error(f'{name}: tool_calls need a name and arguments object')
- if scenario.get('status', 200) not in (200, 401, 429, 500, 503):
- parser.error(f'{name}: unsupported injected HTTP status')
- for key in ('delay_seconds', 'chunk_delay_seconds'):
- value = scenario.get(key, 0)
- if not isinstance(value, (int, float)) or not 0 <= value <= 60:
- parser.error(f'{name}: {key} must be between 0 and 60')
+ try:
+ validate_scenarios(scenarios)
+ except ValueError as exc:
+ parser.error(str(exc))
with MockServer((args.host, args.port), scenarios, args.record) as server:
print(f'Mock LLM: http://{args.host}:{server.server_port}/v1', flush=True)
try:
diff --git a/tools/mock_llm_scenarios.json b/tools/mock_llm_scenarios.json
index 0753c5b6a..f870a43b6 100644
--- a/tools/mock_llm_scenarios.json
+++ b/tools/mock_llm_scenarios.json
@@ -165,5 +165,135 @@
"content": "LANGBOT_OTHER_RAG_OK"
}
]
+ },
+ "mock-daily-tool-recovery": {
+ "steps": [
+ {
+ "tool_calls": [
+ {
+ "name": "exec",
+ "arguments": {
+ "command": "printf EXPECTED_RECOVERY_ERROR >&2; exit 7"
+ }
+ }
+ ]
+ },
+ {
+ "expect_tool_output": "EXPECTED_RECOVERY_ERROR",
+ "tool_calls": [
+ {
+ "name": "exec",
+ "arguments": {
+ "command": "printf RECOVERY_OK"
+ }
+ }
+ ]
+ },
+ {
+ "expect_tool_output": "RECOVERY_OK",
+ "content": "LANGBOT_TOOL_RECOVERY_OK"
+ }
+ ]
+ },
+ "mock-daily-tool-timeout-recovery": {
+ "steps": [
+ {
+ "tool_calls": [
+ {
+ "name": "exec",
+ "arguments": {
+ "command": "rm -f /tmp/langbot-timeout-leak; sleep 3; printf LEAK > /tmp/langbot-timeout-leak",
+ "timeout_sec": 1
+ }
+ }
+ ]
+ },
+ {
+ "expect_tool_output": "timed_out",
+ "tool_calls": [
+ {
+ "name": "exec",
+ "arguments": {
+ "command": "sleep 3; test ! -e /tmp/langbot-timeout-leak && printf TIMEOUT_RECOVERY_OK"
+ }
+ }
+ ]
+ },
+ {
+ "expect_tool_output": "TIMEOUT_RECOVERY_OK",
+ "content": "LANGBOT_TOOL_TIMEOUT_RECOVERY_OK"
+ }
+ ]
+ },
+ "mock-daily-empty-rag-chat": {
+ "steps": [
+ {
+ "forbid_prompt": [
+ "aurora-parser-rag-9137",
+ "cedar-isolation-4826"
+ ],
+ "content": "LANGBOT_EMPTY_RAG_CHAT_OK"
+ }
+ ]
+ },
+ "mock-stream-disconnect": {
+ "disconnect_after_chunks": 2,
+ "steps": [
+ {
+ "content": "LANGBOT_STREAM_OK"
+ }
+ ]
+ },
+ "mock-stream-stall": {
+ "stall_after_chunks": 2,
+ "stall_seconds": 2,
+ "steps": [
+ {
+ "content": "LANGBOT_STREAM_OK"
+ }
+ ]
+ },
+ "mock-recover-503": {
+ "status": 503,
+ "recover_after": 1,
+ "steps": [
+ {
+ "content": "LANGBOT_RECOVERY_OK"
+ }
+ ]
+ },
+ "mock-recover-stream-disconnect": {
+ "disconnect_after_chunks": 2,
+ "recover_after": 1,
+ "steps": [
+ {
+ "content": "LANGBOT_STREAM_RECOVERY_OK"
+ }
+ ]
+ },
+ "mock-embedding-429": {
+ "kind": "embedding",
+ "status": 429
+ },
+ "mock-embedding-503": {
+ "kind": "embedding",
+ "status": 503
+ },
+ "mock-embedding-timeout": {
+ "kind": "embedding",
+ "delay_seconds": 2
+ },
+ "mock-embedding-wrong-count": {
+ "kind": "embedding",
+ "embedding_fault": "count"
+ },
+ "mock-embedding-wrong-dimension": {
+ "kind": "embedding",
+ "embedding_fault": "dimension"
+ },
+ "mock-embedding-recover-503": {
+ "kind": "embedding",
+ "status": 503,
+ "recover_after": 1
}
}
diff --git a/tools/published-plugins.md b/tools/published-plugins.md
new file mode 100644
index 000000000..27550cc55
--- /dev/null
+++ b/tools/published-plugins.md
@@ -0,0 +1,52 @@
+# Published plugins in regression runs
+
+`regression_ci.py` and `regression_job.py` default to the latest published
+LocalAgent, LangRAG and GeneralParsers packages from the official LangBot
+marketplace. Plugin preparation happens before business testing. Each run reads
+`latest_version` once for each plugin, downloads that exact version, and fails
+if metadata, downloads, package identity, installation or initialization fail.
+There is no fallback to older releases, cached packages or a source checkout.
+
+The source runner needs the normal Core/SDK dependencies, Docker and `lbctl`:
+
+```sh
+python tools/regression_ci.py --lbctl /path/to/lbctl --output /path/to/new-run
+```
+
+The released deployment runner uses its existing job profile:
+
+```sh
+python tools/regression_job.py run --profile tools/regression_profiles/job.json --output /path/to/new-run
+```
+
+The job's default `plugin_mode` is `published`; its suite's `plugins` entries
+are replaced by the published preparation. Set `"plugin_mode": "profile"` in
+the job JSON to explicitly use the suite's existing local/GitHub/marketplace
+inputs. For source development, `regression_ci.py --plugins /path/to/langbot-plugins`
+retains packaging from a source checkout.
+
+Each published run keeps `plugins/manifest.json` and the downloaded ZIPs under
+its output directory. The manifest records the official metadata/download URLs,
+selected versions, SHA-256 hashes and installation readback (actual version,
+enabled state and initialized status). Archive identity/version must match the
+release metadata; the CLI must report that same version as enabled and initialized.
+Dependency or SDK incompatibility is a preparation failure and is not downgraded.
+
+To replay an earlier run, pass its manifest to the source runner:
+
+```sh
+python tools/regression_ci.py --lbctl /path/to/lbctl --output /path/to/replay --plugin-manifest /path/to/earlier/plugins/manifest.json
+```
+
+For the deployment runner, set `"plugin_manifest": "/path/to/earlier/plugins/manifest.json"`
+in the job JSON. Relative paths are resolved from the job profile directory.
+Replay downloads the recorded versions from the official marketplace without
+querying latest metadata and requires the recorded hashes. Missing or changed
+packages fail; retain the manifest and ZIPs as evidence. Supplied URLs cannot
+redirect replay to another registry or add other plugin identities.
+
+Installation uses `lbctl plugin install local --file --wait`
+so the installed bytes are the ones whose hashes were recorded. This exercises
+published package compatibility and initialization; it does not exercise the
+marketplace install/upgrade endpoint itself. Workflow callers must omit their
+explicit `--plugins` argument to select the default published mode.
diff --git a/tools/regression_ci.py b/tools/regression_ci.py
index bb8ab24ec..a50706480 100644
--- a/tools/regression_ci.py
+++ b/tools/regression_ci.py
@@ -15,6 +15,7 @@ import time
import yaml
from regression_gate import supervise
+from regression_plugins import install_published_plugins, prepare_published_plugins
from regression_suite import Suite
from regression_traffic import owned_box_containers, read_endpoint_snapshot
@@ -68,6 +69,9 @@ def run(args):
return process.pid
try:
+ published = None
+ if args.plugins is None:
+ published = prepare_published_plugins(root / 'plugins', args.plugin_manifest)
config_path = create_minimal_config(private, port=15301)
create_test_directories(private)
config = yaml.safe_load(config_path.read_text())
@@ -174,12 +178,15 @@ def run(args):
cli_data = yaml.safe_load(cli_config.read_text())
cli_data['contexts']['smoke']['expected_workspace_uuid'] = identity['workspace_uuid']
cli_config.write_text(yaml.safe_dump(cli_data))
- for directory in ('Runner/LocalAgent', 'KnowledgeEngine/LangRAG', 'misc/GeneralParsers'):
- source = args.plugins / directory
- package_source = private / source.name
- shutil.copytree(source, package_source, ignore=shutil.ignore_patterns('.git', '.venv*', '__pycache__'))
- archive = shutil.make_archive(str(private / source.name), 'zip', root_dir=package_source)
- suite.call(['plugin', 'install', 'local', '--file', archive, '--wait'])
+ if published is not None:
+ install_published_plugins(suite.call, root / 'plugins', published)
+ else:
+ for directory in ('Runner/LocalAgent', 'KnowledgeEngine/LangRAG', 'misc/GeneralParsers'):
+ source = args.plugins / directory
+ package_source = private / source.name
+ shutil.copytree(source, package_source, ignore=shutil.ignore_patterns('.git', '.venv*', '__pycache__'))
+ archive = shutil.make_archive(str(private / source.name), 'zip', root_dir=package_source)
+ suite.call(['plugin', 'install', 'local', '--file', archive, '--wait'])
generated = private / f'{args.suite}.json'
generated.write_text(json.dumps(profile, indent=2))
return supervise(
@@ -217,7 +224,9 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--lbctl', type=Path, required=True)
- parser.add_argument('--plugins', type=Path, required=True)
+ plugins = parser.add_mutually_exclusive_group()
+ plugins.add_argument('--plugins', type=Path, help='Explicit developer override: package a plugin source checkout')
+ plugins.add_argument('--plugin-manifest', type=Path, help='Replay published versions and hashes from a prior run')
parser.add_argument('--suite', choices=('smoke', 'endurance'), default='smoke')
args = parser.parse_args()
if args.output.exists():
diff --git a/tools/regression_contracts.json b/tools/regression_contracts.json
new file mode 100644
index 000000000..22334eec1
--- /dev/null
+++ b/tools/regression_contracts.json
@@ -0,0 +1,283 @@
+{
+ "version": 1,
+ "scope": "Registered CLI/HTTP Bot, conversation, model, tool and retrieval behavior obligations plus explicit adjacent-risk gaps. This is not all product features, all API endpoints, code coverage, or real-model quality coverage. Case repetition does not increase the denominator.",
+ "contracts": [
+ {"id": "configuration.identity",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "CLI resolves the configured Workspace identity",
+ "bindings": [{"profile": "smoke", "case": "identity", "checks": ["response"]}]
+ },
+ {"id": "configuration.plugins",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Installed LocalAgent is visible through the plugin API",
+ "bindings": [{"profile": "smoke", "case": "plugins", "checks": ["response"]}],
+ "routes": ["GET /api/v1/plugins"]
+ },
+ {"id": "configuration.provider_readback",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Created provider is visible through the public API",
+ "bindings": [{"profile": "smoke", "case": "provider_readback", "checks": ["response"]}],
+ "routes": ["GET /api/v1/provider/providers/"]
+ },
+ {"id": "configuration.model_readback",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Created model is visible through the public API",
+ "bindings": [{"profile": "smoke", "case": "model_readback", "checks": ["response"]}],
+ "routes": ["GET /api/v1/provider/models/llm/"]
+ },
+ {"id": "configuration.pipeline_readback",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Pipeline readback includes its configured model",
+ "bindings": [{"profile": "smoke", "case": "pipeline_readback", "checks": ["response"]}],
+ "routes": ["GET /api/v1/pipelines/"]
+ },
+ {"id": "configuration.kb_readback",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Knowledge-base identity and engine match configuration",
+ "bindings": [{"profile": "smoke", "case": "kb_readback", "checks": ["response", "fields"]}],
+ "routes": ["GET /api/v1/knowledge/bases/"]
+ },
+ {"id": "configuration.document_readback",
+ "area": "configuration", "kind": "blackbox", "priority": "P1",
+ "description": "Uploaded document completes ingestion in its owning knowledge base",
+ "bindings": [{"profile": "smoke", "case": "document_readback", "checks": ["response", "fields"]}],
+ "routes": ["GET /api/v1/knowledge/bases//files"]
+ },
+ {"id": "ingress.missing_signature",
+ "area": "ingress", "kind": "blackbox", "priority": "P0",
+ "description": "Unsigned bot messages are rejected",
+ "bindings": [{"profile": "smoke", "case": "missing_signature", "checks": ["response", "fields"]}]
+ },
+ {"id": "ingress.bad_signature",
+ "area": "ingress", "kind": "blackbox", "priority": "P0",
+ "description": "Messages with an invalid signature are rejected",
+ "bindings": [{"profile": "smoke", "case": "bad_signature", "checks": ["response", "fields"]}]
+ },
+ {"id": "conversation.chat",
+ "area": "conversation", "kind": "blackbox", "priority": "P1",
+ "description": "Pipeline returns the expected chat response",
+ "bindings": [{"profile": "smoke", "case": "chat", "checks": ["response"]}, {"profile": "endurance", "case": "chat", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "conversation.unicode",
+ "area": "conversation", "kind": "blackbox", "priority": "P1",
+ "description": "Unicode input and streamed output survive the full conversation chain",
+ "bindings": [{"profile": "smoke", "case": "unicode", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "model.slow",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "Slow streamed model output reaches the caller",
+ "bindings": [{"profile": "smoke", "case": "slow", "checks": ["response", "model_sequence"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "model.fallback401",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "Authentication failure falls back to the configured secondary model",
+ "bindings": [{"profile": "smoke", "case": "fallback401", "checks": ["response", "model_sequence"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "model.fallback429",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "Rate limiting falls back to the configured secondary model",
+ "bindings": [{"profile": "smoke", "case": "fallback429", "checks": ["response", "model_sequence"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "model.fallback503",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "Unavailable primary model falls back to the secondary model",
+ "bindings": [{"profile": "smoke", "case": "fallback503", "checks": ["response", "model_sequence"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "tools.exec_error",
+ "area": "tools", "kind": "blackbox", "priority": "P1",
+ "description": "Tool error output is delivered to the model and handled",
+ "bindings": [{"profile": "smoke", "case": "exec_error", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "tools.parallel",
+ "area": "tools", "kind": "blackbox", "priority": "P1",
+ "description": "Parallel tool calls return matched results to the model",
+ "bindings": [{"profile": "smoke", "case": "parallel", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "delivery.unicode_export",
+ "area": "delivery", "kind": "blackbox", "priority": "P1",
+ "description": "Exported Unicode file name and bytes survive delivery",
+ "bindings": [{"profile": "smoke", "case": "unicode_export", "checks": ["response", "attachment"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "retrieval.other_rag",
+ "area": "retrieval", "kind": "blackbox", "priority": "P1",
+ "description": "A second knowledge base supplies its own content to the model",
+ "bindings": [{"profile": "smoke", "case": "other_rag", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "tools.box",
+ "area": "tools", "kind": "blackbox", "priority": "P1",
+ "description": "A real Box write/read round trip reaches the final reply",
+ "bindings": [{"profile": "smoke", "case": "box", "checks": ["response"]}, {"profile": "endurance", "case": "box", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "delivery.box_export",
+ "area": "delivery", "kind": "blackbox", "priority": "P1",
+ "description": "Exported file bytes match the generated fixture",
+ "bindings": [{"profile": "smoke", "case": "box_export", "checks": ["response", "attachment"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "retrieval.rag_chat",
+ "area": "retrieval", "kind": "blackbox", "priority": "P1",
+ "description": "Retrieved fixture content reaches the model and final reply",
+ "bindings": [{"profile": "smoke", "case": "rag_chat", "checks": ["response"]}, {"profile": "endurance", "case": "rag_chat", "checks": ["response"]}],
+ "routes": ["POST /api/v1/pipelines//run"]
+ },
+ {"id": "retrieval.rag_retrieve",
+ "area": "retrieval", "kind": "blackbox", "priority": "P0",
+ "description": "Retrieval returns the owning knowledge-base marker without the other fixture",
+ "bindings": [{"profile": "smoke", "case": "rag_retrieve", "checks": ["response", "fields", "isolation"]}],
+ "routes": ["POST /api/v1/knowledge/bases//retrieve"]
+ },
+ {"id": "retrieval.retrieve_other",
+ "area": "retrieval", "kind": "blackbox", "priority": "P0",
+ "description": "Retrieval from the second knowledge base excludes the first fixture",
+ "bindings": [{"profile": "smoke", "case": "retrieve_other", "checks": ["response", "fields", "isolation"]}],
+ "routes": ["POST /api/v1/knowledge/bases//retrieve"]
+ },
+ {"id": "retrieval.retrieve_empty",
+ "area": "retrieval", "kind": "blackbox", "priority": "P1",
+ "description": "Empty knowledge base returns an empty retrieval result",
+ "bindings": [{"profile": "smoke", "case": "retrieve_empty", "checks": ["response", "fields"]}],
+ "routes": ["POST /api/v1/knowledge/bases//retrieve"]
+ },
+ {"id": "conversation.group_chat_history",
+ "area": "conversation", "kind": "blackbox", "priority": "P0",
+ "description": "Multi-turn chat preserves the prior reply without mixing group history",
+ "bindings": [{"profile": "smoke", "case": "group_chat_history", "checks": ["response"], "min_turn": 2}, {"profile": "endurance", "case": "chat", "checks": ["response"], "min_turn": 2}]
+ },
+ {"id": "conversation.group_box_history",
+ "area": "conversation", "kind": "blackbox", "priority": "P1",
+ "description": "Multi-turn Box conversation preserves group history",
+ "bindings": [{"profile": "smoke", "case": "group_box_history", "checks": ["response"], "min_turn": 2}, {"profile": "endurance", "case": "box", "checks": ["response"], "min_turn": 2}]
+ },
+ {"id": "conversation.group_rag_history",
+ "area": "conversation", "kind": "blackbox", "priority": "P1",
+ "description": "Multi-turn RAG conversation preserves group history",
+ "bindings": [{"profile": "smoke", "case": "group_rag_history", "checks": ["response"], "min_turn": 2}, {"profile": "endurance", "case": "rag_chat", "checks": ["response"], "min_turn": 2}]
+ },
+ {"id": "auth.invalid_credentials",
+ "area": "auth", "kind": "blackbox", "priority": "P0",
+ "description": "Missing and invalid API credentials cannot access Workspace resources",
+ "bindings": []
+ },
+ {"id": "auth.revocation",
+ "area": "auth", "kind": "blackbox", "priority": "P0",
+ "description": "Revoked credentials stop authorizing new requests",
+ "bindings": []
+ },
+ {"id": "workspace.read_isolation",
+ "area": "workspace", "kind": "blackbox", "priority": "P0",
+ "description": "One Workspace cannot read another Workspace resource",
+ "bindings": []
+ },
+ {"id": "workspace.write_isolation",
+ "area": "workspace", "kind": "blackbox", "priority": "P0",
+ "description": "One Workspace cannot change another Workspace resource",
+ "bindings": []
+ },
+ {"id": "execution.cancel_cleanup",
+ "area": "execution", "kind": "blackbox", "priority": "P0",
+ "description": "Cancelling a running execution reaches cancelled state and releases its work",
+ "bindings": []
+ },
+ {"id": "execution.timeout_cleanup",
+ "area": "execution", "kind": "blackbox", "priority": "P0",
+ "description": "Deadline expiry stops child work and allows a subsequent request",
+ "bindings": []
+ },
+ {"id": "execution.retry_side_effects",
+ "area": "execution", "kind": "blackbox", "priority": "P0",
+ "description": "Retries do not duplicate externally visible side effects",
+ "bindings": []
+ },
+ {"id": "runtime.restart_recovery",
+ "area": "runtime", "kind": "blackbox", "priority": "P0",
+ "description": "A runtime restart produces a known outcome and subsequent execution works",
+ "bindings": []
+ },
+ {"id": "storage.restart_history",
+ "area": "storage", "kind": "blackbox", "priority": "P1",
+ "description": "Conversation history remains usable across a Core restart",
+ "bindings": []
+ },
+ {"id": "model.stream_disconnect",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "A partial model stream disconnect is handled without a false successful completion",
+ "bindings": []
+ },
+ {"id": "model.exhausted_fallback",
+ "area": "model", "kind": "blackbox", "priority": "P1",
+ "description": "Failure of every configured model reaches a visible failed outcome",
+ "bindings": []
+ },
+ {"id": "tools.path_escape",
+ "area": "tools", "kind": "blackbox", "priority": "P0",
+ "description": "Box operations reject access outside their authorized filesystem scope",
+ "bindings": []
+ },
+ {"id": "tools.timeout_recovery",
+ "area": "tools", "kind": "blackbox", "priority": "P1",
+ "description": "Timed-out tools stop running and the next tool can execute",
+ "bindings": []
+ },
+ {"id": "delivery.callback_retry",
+ "area": "delivery", "kind": "blackbox", "priority": "P1",
+ "description": "Failed delivery callbacks retry according to policy without duplicate effects",
+ "bindings": []
+ },
+ {"id": "retrieval.update_delete",
+ "area": "retrieval", "kind": "blackbox", "priority": "P1",
+ "description": "Updated/deleted documents change retrieval results without stale leakage",
+ "bindings": []
+ },
+ {"id": "plugins.upgrade_recovery",
+ "area": "plugins", "kind": "blackbox", "priority": "P1",
+ "description": "Plugin upgrade failure preserves a usable installation",
+ "bindings": []
+ },
+ {"id": "agent.direct_execution",
+ "area": "agent", "kind": "blackbox", "priority": "P1",
+ "description": "Standalone Agent entrypoint completes through the public API",
+ "bindings": []
+ },
+ {"id": "persistence.postgres_parity",
+ "area": "persistence", "kind": "blackbox", "priority": "P1",
+ "description": "The same externally visible run contract holds on PostgreSQL",
+ "bindings": []
+ },
+ {"id": "platform.real_adapter",
+ "area": "platform", "kind": "blackbox", "priority": "P2",
+ "description": "An actual messaging-platform adapter receives and delivers the fixture",
+ "bindings": []
+ },
+ {"id": "retrieval.semantic_embedding",
+ "area": "retrieval", "kind": "blackbox", "priority": "P2",
+ "description": "A real embedding model retrieves relevant fixed documents",
+ "bindings": []
+ },
+ {"id": "execution.persisted_terminal",
+ "area": "execution", "kind": "graybox", "priority": "P0",
+ "description": "Each executed request has a unique completed persisted run and terminal event",
+ "bindings": [{"profile": "smoke", "case": "chat", "checks": ["ledger"]}, {"profile": "endurance", "case": "chat", "checks": ["ledger"]}]
+ },
+ {"id": "tools.persisted_box_results",
+ "area": "tools", "kind": "graybox", "priority": "P0",
+ "description": "Persisted Box events match backend, exit status and output",
+ "bindings": [{"profile": "smoke", "case": "box", "checks": ["ledger"]}, {"profile": "endurance", "case": "box", "checks": ["ledger"]}]
+ },
+ {"id": "ingress.no_run_on_reject",
+ "area": "ingress", "kind": "graybox", "priority": "P0",
+ "description": "Rejected ingress does not create an AgentRun",
+ "bindings": [{"profile": "smoke", "case": "missing_signature", "checks": ["no_run"]}]
+ }
+ ]
+}
diff --git a/tools/regression_coverage.py b/tools/regression_coverage.py
new file mode 100644
index 000000000..73ed9232d
--- /dev/null
+++ b/tools/regression_coverage.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""Report explicitly asserted business contracts, independently of pass rate."""
+
+import argparse
+import ast
+from collections import Counter
+import json
+import hashlib
+import re
+from urllib.parse import urlsplit
+from pathlib import Path
+
+CATALOG = Path(__file__).with_name('regression_contracts.json')
+STATES = ('passed', 'failed', 'incomplete', 'skipped', 'not_run', 'missing_test')
+
+
+def checked(row, name, action):
+ """Record an evaluated assertion; transport failures leave it unverified."""
+ assertions = row.setdefault('assertions', {})
+ try:
+ result = action()
+ except ValueError:
+ assertions[name] = 'failed'
+ raise
+ assertions[name] = 'passed'
+ return result
+
+
+def check_response(data, expected, forbidden=()):
+ text = json.dumps(data, ensure_ascii=False)
+ if expected not in text:
+ raise ValueError('Response does not contain the expected business result')
+ if any(value in text for value in forbidden):
+ raise ValueError('Response contains forbidden content')
+
+
+def load_catalog(path=CATALOG):
+ catalog = json.loads(Path(path).read_text())
+ if catalog.get('version') != 1 or not catalog.get('scope'):
+ raise ValueError('Coverage catalog requires version 1 and an explicit scope')
+ seen = set()
+ for item in catalog['contracts']:
+ if item['id'] in seen or item['kind'] not in ('blackbox', 'graybox'):
+ raise ValueError('Coverage contract IDs must be unique and kinds explicit')
+ seen.add(item['id'])
+ if not item['description'] or not item['area'] or item['priority'] not in ('P0', 'P1', 'P2'):
+ raise ValueError('Coverage contracts require a description, area and priority')
+ for binding in item['bindings']:
+ if not binding['profile'] or not binding['case'] or not binding['checks']:
+ raise ValueError('Coverage bindings require a profile, case and actual assertions')
+ return catalog
+
+
+def collect(directory):
+ directory = Path(directory)
+ profile = json.loads((directory / 'profile.json').read_text())
+ suite = profile.get('coverage_suite', 'unclassified')
+ sources = (
+ [directory / 'cases.json']
+ if 'smoke' in profile
+ else [directory / phase['name'] / 'requests.jsonl' for phase in profile.get('phases', [])]
+ )
+ rows, errors = [], []
+ for path in sources:
+ if not path.exists():
+ errors.append(f'{path}: missing executed-case evidence')
+ continue
+ try:
+ data = (
+ [json.loads(line) for line in path.read_text().splitlines()]
+ if path.suffix == '.jsonl'
+ else json.loads(path.read_text())
+ )
+ if not isinstance(data, list) or any(
+ not isinstance(row, dict)
+ or row.get('status') not in ('passed', 'ok', 'failed', 'unknown', 'skipped')
+ or not isinstance(row.get('case'), str)
+ or not isinstance(row.get('assertions', {}), dict)
+ or any(value not in ('passed', 'failed') for value in row.get('assertions', {}).values())
+ or not isinstance(row.get('turn', 0), int)
+ for row in data
+ ):
+ raise ValueError('Malformed executed-case evidence')
+ if not data:
+ errors.append(f'{path}: empty executed-case evidence')
+ rows.extend({**row, 'profile': suite, 'source': str(path)} for row in data)
+ except (ValueError, OSError) as exc:
+ errors.append(f'{path}: {exc}')
+ missing = {case['name'] for case in profile.get('cases', [])} - {row['case'] for row in rows}
+ if missing:
+ errors.append(f'{directory}: no evidence for planned cases: {sorted(missing)}')
+ return rows, errors
+
+
+def route_inventory(catalog, root=None):
+ """Discover RouterGroup HTTP declarations; links express test intent, not endpoint hits."""
+ root = Path(root) if root else Path(__file__).resolve().parents[1]
+ routes, unresolved = [], []
+ for path in sorted((root / 'src/langbot/pkg').rglob('*.py')):
+ if 'group_class' not in path.read_text():
+ continue
+ tree = ast.parse(path.read_text())
+ for cls in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
+ groups = [
+ d
+ for d in cls.decorator_list
+ if isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute) and d.func.attr == 'group_class'
+ ]
+ if not groups:
+ continue
+ try:
+ prefix = ast.literal_eval(groups[0].args[1])
+ except (ValueError, IndexError):
+ unresolved.append(f'{path.relative_to(root)}:{cls.lineno}')
+ continue
+ for func in (node for node in ast.walk(cls) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))):
+ for dec in func.decorator_list:
+ if (
+ not isinstance(dec, ast.Call)
+ or not isinstance(dec.func, ast.Attribute)
+ or dec.func.attr != 'route'
+ ):
+ continue
+ location = f'{path.relative_to(root)}:{func.lineno}'
+ try:
+ rule = ast.literal_eval(dec.args[0])
+ methods = next((ast.literal_eval(k.value) for k in dec.keywords if k.arg == 'methods'), ['GET'])
+ if not isinstance(prefix, str) or not isinstance(rule, str) or not isinstance(methods, list):
+ raise ValueError('Dynamic route declaration')
+ except (ValueError, IndexError):
+ unresolved.append(location)
+ continue
+ for method in methods:
+ key = f'{method.upper()} {prefix}{rule}'
+ routes.append(
+ {
+ 'route': key,
+ 'source': location,
+ 'contracts': [c['id'] for c in catalog['contracts'] if key in c.get('routes', [])],
+ }
+ )
+ return {
+ 'scope': 'Static RouterGroup HTTP declarations only; excludes WebSockets, platform-dispatched and dynamic routes. Contract links are intent, not HTTP hit coverage.',
+ 'routes': routes,
+ 'unresolved': unresolved,
+ }
+
+
+def http_observations(paths, inventory):
+ """Map Hypercorn access records to declared routes without retaining request URLs."""
+ patterns, unsupported = [], []
+ for route in inventory['routes']:
+ method, path = route['route'].split(' ', 1)
+ parts = re.split(r'(<[^>]+>)', path)
+ if any(part.startswith('<') and ':' in part and not part.startswith(('')
+ else re.escape(part)
+ for part in parts
+ )
+ literal_length = sum(len(part) for part in parts if not part.startswith('<'))
+ patterns.append((literal_length, method, re.compile(pattern), route['route']))
+ patterns.sort(key=lambda item: item[0], reverse=True)
+ access = re.compile(r'\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) (\S+) (?:HTTP/)?[123]\.[01] (\d{3})\b')
+ matched, unclassified, records, errors = {}, Counter(), 0, []
+ for path in paths:
+ try:
+ with Path(path).open() as stream:
+ for line_number, line in enumerate(stream, 1):
+ match = access.search(line)
+ if not match:
+ continue
+ method, target, status = match.groups()
+ records += 1
+ try:
+ request_path = urlsplit(target).path
+ except ValueError:
+ errors.append(f'{path}:{line_number}: invalid request target')
+ unclassified[f'{method} {status}'] += 1
+ continue
+ route = next(
+ (
+ name
+ for _, expected_method, pattern, name in patterns
+ if method == expected_method and pattern.fullmatch(request_path)
+ ),
+ None,
+ )
+ if route is None:
+ unclassified[f'{method} {status}'] += 1
+ else:
+ matched.setdefault(route, Counter())[status] += 1
+ except (OSError, ValueError) as exc:
+ errors.append(f'{path}: {exc}')
+ if paths and not records:
+ errors.append('No HTTP access records were recognized')
+ return {
+ 'scope': 'Observed HTTP methods and declared route templates; this does not prove behavior assertions or adapter sub-path coverage.',
+ 'records': records,
+ 'routes': [{'route': route, 'statuses': dict(statuses)} for route, statuses in sorted(matched.items())],
+ 'unclassified': dict(unclassified),
+ 'unsupported_templates': unsupported,
+ 'evidence_errors': errors,
+ }
+
+
+def counts(items):
+ status = Counter(item['status'] for item in items)
+ decided = status['passed'] + status['failed']
+ return {
+ 'total': len(items),
+ 'decided': decided,
+ 'coverage_percent': round(100 * decided / len(items), 2) if items else None,
+ 'pass_percent': round(100 * status['passed'] / decided, 2) if decided else None,
+ 'status': {name: status[name] for name in STATES},
+ }
+
+
+def report(catalog, rows, errors=()):
+ items, bound = [], set()
+ for contract in catalog['contracts']:
+ observations = []
+ for binding in contract['bindings']:
+ bound.add((binding['profile'], binding['case']))
+ for row in rows:
+ if (row['profile'], row['case']) != (binding['profile'], binding['case']):
+ continue
+ if row.get('turn', 0) < binding.get('min_turn', 0):
+ continue
+ checks = [row.get('assertions', {}).get(name) for name in binding['checks']]
+ status = (
+ 'failed'
+ if 'failed' in checks
+ else 'skipped'
+ if row['status'] == 'skipped'
+ else 'passed'
+ if all(value == 'passed' for value in checks)
+ else 'incomplete'
+ )
+ observations.append({'status': status, 'case': row['case'], 'profile': row['profile']})
+ statuses = {row['status'] for row in observations}
+ status = (
+ 'missing_test'
+ if not contract['bindings']
+ else 'not_run'
+ if not observations
+ else 'failed'
+ if 'failed' in statuses
+ else 'skipped'
+ if statuses == {'skipped'}
+ else 'passed'
+ if statuses == {'passed'}
+ else 'incomplete'
+ )
+ items.append({**contract, 'status': status, 'observations': dict(Counter(x['status'] for x in observations))})
+ return {
+ 'version': 1,
+ 'scope': catalog['scope'],
+ 'catalog_version': catalog['version'],
+ 'catalog_sha256': hashlib.sha256(json.dumps(catalog, sort_keys=True).encode()).hexdigest(),
+ 'complete_evidence': not errors,
+ 'kinds': {kind: counts([x for x in items if x['kind'] == kind]) for kind in ('blackbox', 'graybox')},
+ 'areas': {
+ area: counts([x for x in items if x['kind'] == 'blackbox' and x['area'] == area])
+ for area in sorted({x['area'] for x in items if x['kind'] == 'blackbox'})
+ },
+ 'contracts': items,
+ 'unclassified_cases': [
+ {'profile': p, 'case': c} for p, c in sorted({(row['profile'], row['case']) for row in rows} - bound)
+ ],
+ 'evidence_errors': list(errors),
+ }
+
+
+def write_report(directories, output, catalog_path=CATALOG, http_logs=()):
+ rows, errors = [], []
+ for directory in directories:
+ try:
+ found, invalid = collect(directory)
+ rows.extend(found)
+ errors.extend(invalid)
+ except (ValueError, OSError, KeyError) as exc:
+ errors.append(f'{directory}: {exc}')
+ catalog = load_catalog(catalog_path)
+ data = report(catalog, rows, errors)
+ data['route_inventory'] = route_inventory(catalog)
+ if http_logs:
+ data['http_observations'] = http_observations(http_logs, data['route_inventory'])
+ errors.extend(data['http_observations']['evidence_errors'])
+ data['evidence_errors'] = list(errors)
+ data['complete_evidence'] = not errors
+ output = Path(output)
+ output.mkdir(parents=True, exist_ok=True)
+ (output / 'coverage.json').write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
+ lines = [
+ '# Business behavior coverage',
+ '',
+ data['scope'],
+ '',
+ 'Coverage counts evaluated contracts (pass or fail), not requests or code lines.',
+ 'Incomplete evidence and skipped/not-run contracts do not count. Gray-box evidence is separate.',
+ '',
+ '| Evidence | Decided / registered | Coverage | Pass rate |',
+ '|---|---:|---:|---:|',
+ ]
+ for kind, values in data['kinds'].items():
+ lines.append(
+ f'| {kind} | {values["decided"]}/{values["total"]} | {values["coverage_percent"]}% | {values["pass_percent"]}% |'
+ )
+ lines += ['', '| Priority | Contract | Area | Evidence | Status |', '|---|---|---|---|---|']
+ for item in sorted(data['contracts'], key=lambda x: (x['priority'], x['area'], x['id'])):
+ lines.append(
+ f'| {item["priority"]} | {item["id"]}: {item["description"]} | {item["area"]} | {item["kind"]} | {item["status"]} |'
+ )
+ inventory = data['route_inventory']
+ unlinked = sum(not r['contracts'] for r in inventory['routes'])
+ lines += [
+ '',
+ f'Route inventory: {len(inventory["routes"])} HTTP declarations; {unlinked} have no linked behavior contract; {len(inventory["unresolved"])} need manual classification.',
+ inventory['scope'],
+ 'Full route list and source locations are in coverage.json.',
+ ]
+ if http_logs:
+ observed = data['http_observations']
+ lines += [
+ '',
+ f'Observed HTTP: {len(observed["routes"])} declared routes, {observed["records"]} access records.',
+ observed['scope'],
+ '| Route | HTTP statuses and counts |',
+ '|---|---|',
+ ]
+ lines.extend(f'| {row["route"]} | {row["statuses"]} |' for row in observed['routes'])
+ lines.append(f'Unclassified method/status counts: {observed["unclassified"]}')
+ for item in data['unclassified_cases']:
+ lines.append(f'\nUnclassified case: {item["profile"]}/{item["case"]}')
+ for error in errors:
+ lines.append(f'\nEvidence error: {error}')
+ (output / 'coverage.md').write_text('\n'.join(lines) + '\n')
+ return data
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--run', type=Path, action='append', required=True)
+ parser.add_argument('--output', type=Path, required=True)
+ parser.add_argument('--http-log', type=Path, action='append', default=[])
+ args = parser.parse_args()
+ data = write_report(args.run, args.output, http_logs=args.http_log)
+ print(json.dumps(data['kinds'], indent=2))
+ if data['evidence_errors']:
+ raise SystemExit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/regression_job.py b/tools/regression_job.py
index cd43bcb9e..22b5b7c85 100644
--- a/tools/regression_job.py
+++ b/tools/regression_job.py
@@ -17,6 +17,7 @@ import urllib.request
import yaml
from regression_suite import Suite, number, resolve, validate
+from regression_plugins import install_published_plugins, prepare_published_plugins
from regression_traffic import owned_box_containers
@@ -34,8 +35,30 @@ def load_profile(path):
profile = json.loads(path.read_text())
if not isinstance(profile, dict) or profile.get('version') != 1:
raise ValueError('job profile version must be 1')
- if profile.keys() - {'version', 'lbctl', 'suite', 'deployment', 'vars', 'cleanup', 'purge_data', 'mock_image'}:
+ if profile.keys() - {
+ 'version',
+ 'lbctl',
+ 'suite',
+ 'deployment',
+ 'vars',
+ 'cleanup',
+ 'purge_data',
+ 'mock_image',
+ 'plugin_mode',
+ 'plugin_manifest',
+ }:
raise ValueError('unknown job profile field')
+ if profile.get('plugin_mode', 'published') not in ('published', 'profile'):
+ raise ValueError('plugin_mode must be published/profile')
+ if 'plugin_manifest' in profile:
+ if profile.get('plugin_mode', 'published') != 'published':
+ raise ValueError('plugin_manifest requires published plugin_mode')
+ if not isinstance(profile['plugin_manifest'], str) or not profile['plugin_manifest']:
+ raise ValueError('plugin_manifest must name a prior published manifest')
+ manifest_path = Path(profile['plugin_manifest']).expanduser()
+ if not manifest_path.is_absolute():
+ manifest_path = path.parent / manifest_path
+ profile['plugin_manifest'] = str(manifest_path.resolve())
deployment = profile.get('deployment', {})
if not isinstance(deployment, dict) or deployment.keys() - {'version', 'port'}:
raise ValueError('deployment accepts version and port')
@@ -52,6 +75,8 @@ def load_profile(path):
suite_path = Path(profile['suite']).expanduser()
suite_path = suite_path if suite_path.is_absolute() else path.parent / suite_path
suite = json.loads(suite_path.read_text())
+ if profile.get('plugin_mode', 'published') == 'published':
+ suite['plugins'] = []
suite.setdefault('vars', {}).update(profile.get('vars', {}))
suite['vars'].update(
mock_base_url='http://regression_mock:18080/v1', embedding_base_url='http://regression_mock:18080/v1'
@@ -198,6 +223,12 @@ class Job:
raise RuntimeError(f'readiness deadline exceeded: {url}')
def prepare(self):
+ published = None
+ if self.profile.get('plugin_mode', 'published') == 'published':
+ self.state['phase'] = 'plugins'
+ self.save()
+ published = prepare_published_plugins(self.output / 'plugins', self.profile.get('plugin_manifest'))
+ self.suite_profile['plugins'] = []
variables = {
**self.suite_profile.get('vars', {}),
'profile_dir': str(self.suite_path.parent),
@@ -324,6 +355,12 @@ class Job:
destination.mkdir(mode=0o700)
suite = Suite(profile, self.suite_path, destination, env=self.env)
self.redactions.extend(suite.secrets)
+ if published is not None:
+ self.state['phase'] = 'plugins'
+ self.save()
+ install_published_plugins(suite.call, self.output / 'plugins', published)
+ self.state['phase'] = 'resources'
+ self.save()
suite.write('profile.json', profile)
if 'smoke' not in profile:
from regression_analysis import workload_contract
@@ -403,8 +440,7 @@ class Job:
self.state['phase'] = 'traffic'
self.save()
summary = suite.run()
- summary['passed'] = not summary['errors']
- suite.write('summary.json', summary)
+ suite.finish(summary)
result.update(completed=True, suite=summary, errors=list(summary['errors']))
self.state['phase'] = 'finished'
self.save()
diff --git a/tools/regression_plugins.py b/tools/regression_plugins.py
new file mode 100644
index 000000000..af75d9341
--- /dev/null
+++ b/tools/regression_plugins.py
@@ -0,0 +1,170 @@
+"""Resolve official published regression plugins once, then install the recorded bytes."""
+
+from datetime import datetime, timezone
+import hashlib
+import io
+import json
+from pathlib import Path
+import re
+import urllib.error
+import urllib.request
+import zipfile
+
+import yaml
+
+
+MARKETPLACE = 'https://space.langbot.app'
+PLUGIN_IDS = ('langbot-team/LocalAgent', 'langbot-team/LangRAG', 'langbot-team/GeneralParsers')
+METADATA_LIMIT = 1024 * 1024
+PACKAGE_LIMIT = 64 * 1024 * 1024
+
+
+def write_manifest(directory, manifest):
+ path = directory / 'manifest.json'
+ temporary = directory / 'manifest.json.tmp'
+ temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
+ temporary.replace(path)
+
+
+def fetch(url, limit):
+ request = urllib.request.Request(url, headers={'User-Agent': 'LangBot-regression/1'})
+ try:
+ with urllib.request.urlopen(request, timeout=60) as response:
+ if response.status != 200:
+ raise RuntimeError(f'Marketplace request failed: HTTP {response.status}: {url}')
+ content = response.read(limit + 1)
+ except (urllib.error.URLError, OSError) as exc:
+ raise RuntimeError(f'Marketplace request failed: {url}: {exc}') from exc
+ if len(content) > limit:
+ raise ValueError(f'Marketplace response exceeds {limit} bytes: {url}')
+ return content
+
+
+def release_version(value):
+ if not isinstance(value, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}', value):
+ raise ValueError(f'Invalid published plugin version: {value!r}')
+ return value
+
+
+def replay_versions(path):
+ manifest = json.loads(Path(path).read_text())
+ if not isinstance(manifest, dict) or manifest.get('version') != 1 or manifest.get('marketplace') != MARKETPLACE:
+ raise ValueError('Replay requires a version 1 official marketplace manifest')
+ entries = manifest.get('plugins')
+ if not isinstance(entries, list) or len(entries) != len(PLUGIN_IDS):
+ raise ValueError('Replay manifest must contain the three regression plugins')
+ selected = {}
+ for entry in entries:
+ if not isinstance(entry, dict) or entry.get('id') not in PLUGIN_IDS or entry['id'] in selected:
+ raise ValueError('Replay manifest has an unknown or duplicate plugin')
+ version = release_version(entry.get('version'))
+ digest = entry.get('sha256')
+ if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
+ raise ValueError('Replay manifest requires a package sha256 for every plugin')
+ selected[entry['id']] = (version, digest)
+ return selected
+
+
+def check_archive(content, plugin_id, version):
+ try:
+ with zipfile.ZipFile(io.BytesIO(content)) as archive:
+ if archive.namelist().count('manifest.yaml') != 1:
+ raise ValueError('package needs exactly one root manifest.yaml')
+ info = archive.getinfo('manifest.yaml')
+ if info.file_size > METADATA_LIMIT:
+ raise ValueError('package manifest exceeds the metadata limit')
+ manifest = yaml.safe_load(archive.read(info))
+ metadata = manifest.get('metadata') if isinstance(manifest, dict) else None
+ if (
+ not isinstance(metadata, dict)
+ or manifest.get('kind') != 'Plugin'
+ or f'{metadata.get("author")}/{metadata.get("name")}' != plugin_id
+ or metadata.get('version') != version
+ ):
+ raise ValueError('package manifest identity/version differs from the selected release')
+ except (zipfile.BadZipFile, yaml.YAMLError, KeyError, ValueError, RuntimeError) as exc:
+ raise ValueError(f'Invalid published package for {plugin_id}@{version}: {exc}') from exc
+
+
+def prepare_published_plugins(directory, replay=None):
+ """Download releases; replay reads only identities, versions and hashes, never supplied URLs."""
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=False)
+ selected = replay_versions(replay) if replay is not None else None
+ manifest = {
+ 'version': 1,
+ 'marketplace': MARKETPLACE,
+ 'resolved_at': datetime.now(timezone.utc).isoformat(),
+ 'selection': 'replay' if selected is not None else 'latest',
+ 'plugins': [],
+ }
+ write_manifest(directory, manifest)
+ for plugin_id in PLUGIN_IDS:
+ metadata_url = f'{MARKETPLACE}/api/v1/marketplace/plugins/{plugin_id}'
+ if selected is None:
+ payload = json.loads(fetch(metadata_url, METADATA_LIMIT))
+ data = payload.get('data') if isinstance(payload, dict) else None
+ plugin = data.get('plugin') if isinstance(data, dict) else None
+ if (
+ not isinstance(payload, dict)
+ or payload.get('code') != 0
+ or not isinstance(plugin, dict)
+ or plugin.get('plugin_id') != plugin_id
+ or f'{plugin.get("author")}/{plugin.get("name")}' != plugin_id
+ or plugin.get('status') != 'live'
+ or plugin.get('deleted_at') is not None
+ ):
+ raise ValueError(f'Marketplace did not return a live published plugin: {plugin_id}')
+ version = release_version(plugin.get('latest_version'))
+ else:
+ version = selected[plugin_id][0]
+ manifest['plugins'].append(
+ {
+ 'id': plugin_id,
+ 'version': version,
+ 'metadata_url': metadata_url,
+ 'package_url': f'{MARKETPLACE}/api/v1/marketplace/plugins/download/{plugin_id}/{version}',
+ 'archive': f'{plugin_id.split("/")[1]}-{version}.zip',
+ }
+ )
+ write_manifest(directory, manifest)
+
+ for entry in manifest['plugins']:
+ content = fetch(entry['package_url'], PACKAGE_LIMIT)
+ digest = hashlib.sha256(content).hexdigest()
+ if selected is not None and digest != selected[entry['id']][1]:
+ raise ValueError(f'Replay package sha256 changed: {entry["id"]}@{entry["version"]}')
+ check_archive(content, entry['id'], entry['version'])
+ (directory / entry['archive']).write_bytes(content)
+ entry['sha256'] = digest
+ write_manifest(directory, manifest)
+ return manifest
+
+
+def install_published_plugins(call, directory, manifest):
+ """Install the downloaded packages and require exact, initialized CLI readback."""
+ directory = Path(directory)
+ for entry in manifest['plugins']:
+ archive = directory / entry['archive']
+ if hashlib.sha256(archive.read_bytes()).hexdigest() != entry['sha256']:
+ raise ValueError(f'Plugin archive changed before installation: {entry["id"]}')
+ try:
+ call(['plugin', 'install', 'local', '--file', str(archive), '--wait'])
+ except RuntimeError as exc:
+ raise RuntimeError(f'Published plugin install failed: {entry["id"]}@{entry["version"]}: {exc}') from exc
+ response = call(['plugin', 'list'])
+ plugins = response.get('plugins') if isinstance(response, dict) else None
+ if not isinstance(plugins, list) or any(not isinstance(item, dict) for item in plugins):
+ raise RuntimeError('Plugin readback did not return a plugin list')
+ matches = [item for item in plugins if f'{item.get("author")}/{item.get("name")}' == entry['id']]
+ entry['installed'] = [{key: item.get(key) for key in ('version', 'enabled', 'status')} for item in matches]
+ write_manifest(directory, manifest)
+ if (
+ len(matches) != 1
+ or matches[0].get('version') != entry['version']
+ or matches[0].get('enabled') is not True
+ or matches[0].get('status') != 'initialized'
+ ):
+ raise RuntimeError(
+ f'Published plugin is not ready at the selected version: {entry["id"]}: {entry["installed"]}'
+ )
diff --git a/tools/regression_profiles/endurance.json b/tools/regression_profiles/endurance.json
index 09333718f..6ba9ce173 100644
--- a/tools/regression_profiles/endurance.json
+++ b/tools/regression_profiles/endurance.json
@@ -509,5 +509,6 @@
"pids": 8,
"count": 0
}
- }
+ },
+ "coverage_suite": "endurance"
}
diff --git a/tools/regression_profiles/smoke.json b/tools/regression_profiles/smoke.json
index 123dfa331..c8342eb7f 100644
--- a/tools/regression_profiles/smoke.json
+++ b/tools/regression_profiles/smoke.json
@@ -1640,5 +1640,6 @@
"langbot-team/GeneralParsers"
],
"mock_records": "${mock_records}"
- }
+ },
+ "coverage_suite": "smoke"
}
diff --git a/tools/regression_smoke.py b/tools/regression_smoke.py
index e462844ec..7734ef87c 100644
--- a/tools/regression_smoke.py
+++ b/tools/regression_smoke.py
@@ -12,6 +12,7 @@ import urllib.request
from uuid import uuid4
from regression_traffic import group_request, read_endpoint_snapshot
+from regression_coverage import checked, check_response
class UnresolvedRun(RuntimeError):
@@ -241,27 +242,39 @@ def run_smoke(suite):
offset = mock_path.stat().st_size if case.get('mock_requests') and mock_path.exists() else 0
try:
if rejected:
- data = rejected_group(case, group)
+ data = checked(row, 'response', lambda: rejected_group(case, group))
count = connection.execute(
'SELECT COUNT(*) FROM agent_run WHERE workspace_id=? AND conversation_id=? AND id>?',
(suite.variables['workspace_uuid'], 'group_' + group, after_id),
).fetchone()[0]
- if count:
- raise ValueError('Rejected ingress created an AgentRun')
+ checked(row, 'no_run', lambda: check_fields({'runs': count}, {'runs': 0}))
elif is_group:
- data = group_request(
- case, group, row['member_id'], turn, token, previous[group], suite.env
+ data = checked(
+ row,
+ 'response',
+ lambda: group_request(
+ case, group, row['member_id'], turn, token, previous[group], suite.env
+ ),
)['data']
else:
data = suite.call(case['args'], case.get('body'))
- if case['expect'] not in json.dumps(data, ensure_ascii=False):
- raise ValueError('Expected business response was not returned')
+ checked(row, 'response', lambda: check_response(data, case['expect']))
row['output'] = data
- if any(value in json.dumps(data, ensure_ascii=False) for value in case.get('forbid', [])):
- raise ValueError('Response contains forbidden content from another knowledge base')
+ if case.get('forbid'):
+ checked(row, 'isolation', lambda: check_response(data, case['expect'], case['forbid']))
row['checks'] = {}
if case.get('fields'):
- row['checks']['fields'] = check_fields(data, case['fields'])
+ row['checks']['fields'] = checked(row, 'fields', lambda: check_fields(data, case['fields']))
+ if case.get('attachment'):
+ row['checks']['attachment'] = checked(
+ row, 'attachment', lambda: check_attachment(data, case['attachment'])
+ )
+ if case.get('mock_requests'):
+ row['checks']['mock_requests'] = checked(
+ row,
+ 'model_sequence',
+ lambda: check_mock_requests(mock_path, offset, case['mock_requests']),
+ )
if runner:
if not isinstance(data, dict):
raise ValueError('Runner response must be an object')
@@ -276,13 +289,7 @@ def run_smoke(suite):
settings.get('timeout_seconds', 30),
)
row['run'] = run
- row['checks'].update(check_run(connection, run, case))
- if case.get('attachment'):
- row['checks']['attachment'] = check_attachment(data, case['attachment'])
- if case.get('mock_requests'):
- row['checks']['mock_requests'] = check_mock_requests(
- mock_path, offset, case['mock_requests']
- )
+ row['checks'].update(checked(row, 'ledger', lambda: check_run(connection, run, case)))
row['status'] = 'passed'
previous[group] = token
except urllib.error.HTTPError as exc:
diff --git a/tools/regression_suite.py b/tools/regression_suite.py
index ee6ea0da0..fbab54989 100644
--- a/tools/regression_suite.py
+++ b/tools/regression_suite.py
@@ -80,6 +80,7 @@ def validate(profile):
'smoke',
'verification',
'resource_limits',
+ 'coverage_suite',
}
if extra := profile.keys() - allowed:
raise ValueError(f'unknown profile fields: {sorted(extra)}')
@@ -91,6 +92,8 @@ def validate(profile):
not isinstance(profile[field], list) or any(not isinstance(item, dict) for item in profile[field])
):
raise ValueError(f'{field} must be an array of objects')
+ if 'coverage_suite' in profile and not re.fullmatch(r'[a-z][a-z0-9_-]*', str(profile['coverage_suite'])):
+ raise ValueError('coverage_suite must be a stable suite identifier')
cli = profile.get('cli', {})
if not isinstance(cli, dict) or cli.keys() - {'config', 'executable'}:
raise ValueError('cli accepts config and executable')
@@ -433,6 +436,15 @@ class Suite:
path.chmod(0o600)
return str(path)
+ def finish(self, result):
+ if self.profile.get('coverage_suite'):
+ from regression_coverage import write_report
+
+ coverage = write_report([self.output], self.output)
+ result['errors'].extend(coverage['evidence_errors'])
+ result['passed'] = not result['errors']
+ self.write('summary.json', result)
+
def call(self, args, body=None, *, defer_bot_readback=False):
command = [*self.cli, *args]
if body is not None:
@@ -778,8 +790,7 @@ def main():
result['errors'].extend(suite.cleanup())
except (RuntimeError, ValueError, OSError, KeyError) as exc:
result['errors'].append(f'cleanup failed: {exc}')
- result['passed'] = not result['errors']
- suite.write('summary.json', result)
+ suite.finish(result)
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result['passed']:
raise SystemExit(1)
diff --git a/tools/regression_traffic.py b/tools/regression_traffic.py
index cbd0c5bc9..fdf6d8518 100644
--- a/tools/regression_traffic.py
+++ b/tools/regression_traffic.py
@@ -3,6 +3,7 @@
import argparse
from collections import Counter
+from regression_coverage import checked, check_response
from concurrent.futures import ThreadPoolExecutor
import json
import hashlib
@@ -308,9 +309,11 @@ def main():
data = row['output']['data']
checks = {}
if case.get('fields'):
- checks['fields'] = check_fields(data, case['fields'])
- if any(value in json.dumps(data, ensure_ascii=False) for value in case.get('forbid', [])):
- raise ValueError('Response contains forbidden content')
+ checks['fields'] = checked(row, 'fields', lambda: check_fields(data, case['fields']))
+ if case.get('forbid'):
+ checked(row, 'isolation', lambda: check_response(data, case['expect'], case['forbid']))
+ if case.get('attachment'):
+ checks['attachment'] = checked(row, 'attachment', lambda: check_attachment(data, case['attachment']))
runner = is_group or case.get('args', [])[:2] == ['pipeline', 'run']
row['requires_runner'] = runner
if connection and runner:
@@ -330,13 +333,16 @@ def main():
args.verification_timeout,
)
row['run'] = run
- checks.update(check_run(connection, run, case))
- if case.get('attachment'):
- checks['attachment'] = check_attachment(data, case['attachment'])
- with lock:
- if run['run_id'] in verified_ids:
- raise ValueError('Two requests matched the same AgentRun')
- verified_ids.add(run['run_id'])
+
+ def verify_ledger():
+ result = check_run(connection, run, case)
+ with lock:
+ if run['run_id'] in verified_ids:
+ raise ValueError('Two requests matched the same AgentRun')
+ verified_ids.add(run['run_id'])
+ return result
+
+ checks.update(checked(row, 'ledger', verify_ledger))
row['checks'] = checks
row['verification_seconds'] = time.monotonic() - began
@@ -415,9 +421,8 @@ def main():
row['output'] = json.loads(redact(result.stdout))
if not isinstance(row['output'], dict):
raise ValueError('CLI response must be an object')
- if result.returncode == 0 and case['expect'] in json.dumps(
- row['output'].get('data'), ensure_ascii=False
- ):
+ if result.returncode == 0:
+ checked(row, 'response', lambda: check_response(row['output'].get('data'), case['expect']))
verify(row, case, connection, after_id)
row['status'] = 'ok'
elif 'result_unknown' in result.stdout:
@@ -483,7 +488,11 @@ def main():
elapsed=dispatch - started,
schedule_lag_seconds=max(0, dispatch - group['due']),
)
- row['output'] = group_request(case, group['id'], member, group['turn'], token, group['previous'])
+ row['output'] = checked(
+ row,
+ 'response',
+ lambda: group_request(case, group['id'], member, group['turn'], token, group['previous']),
+ )
row['seconds'] = time.monotonic() - dispatch
verify(row, case, connection, after_id, is_group=True)
row['status'] = 'ok'
diff --git a/tools/test_mock_llm.py b/tools/test_mock_llm.py
index 8c8d74577..2f31f329d 100644
--- a/tools/test_mock_llm.py
+++ b/tools/test_mock_llm.py
@@ -2,16 +2,20 @@
from concurrent.futures import ThreadPoolExecutor
import base64
+from http.client import IncompleteRead
import json
from pathlib import Path
import tempfile
import struct
import threading
+import time
import unittest
+from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
-from mock_llm import MockServer
+import mock_llm
+from mock_llm import MockServer, validate_scenarios
class MockLLMTest(unittest.TestCase):
@@ -19,7 +23,18 @@ class MockLLMTest(unittest.TestCase):
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.record = Path(cls.tmp.name) / 'requests.jsonl'
+ cls.record.touch()
scenarios = json.loads(Path(__file__).with_name('mock_llm_scenarios.json').read_text())
+ scenarios['test-stall-recovery'] = {
+ **scenarios['mock-stream-stall'],
+ 'stall_seconds': 0.2,
+ 'recover_after': 1,
+ }
+ scenarios['test-delay-recovery'] = {
+ 'delay_seconds': 0.2,
+ 'recover_after': 1,
+ 'steps': [{'content': 'RECOVERED'}],
+ }
cls.server = MockServer(('127.0.0.1', 0), scenarios, cls.record)
cls.thread = threading.Thread(target=cls.server.serve_forever)
cls.thread.start()
@@ -32,7 +47,7 @@ class MockLLMTest(unittest.TestCase):
cls.thread.join()
cls.tmp.cleanup()
- def post(self, model='mock-chat', **kwargs):
+ def post(self, model='mock-chat', timeout=5, **kwargs):
body = {
'model': model,
'messages': [{'role': 'user', 'content': 'test'}],
@@ -46,9 +61,34 @@ class MockLLMTest(unittest.TestCase):
'Authorization': 'Bearer must-not-be-recorded',
},
)
- with urlopen(request, timeout=5) as response:
+ with urlopen(request, timeout=timeout) as response:
return response.read().decode()
+ def embed(self, timeout=5, **patch):
+ body = {'model': 'mock-embedding', 'input': ['repeatable fixture', 'another fixture'], **patch}
+ request = Request(
+ self.url + '/v1/embeddings',
+ data=json.dumps(body).encode(),
+ headers={'Content-Type': 'application/json'},
+ )
+ with urlopen(request, timeout=timeout) as response:
+ return json.load(response)
+
+ def records(self, scope, count):
+ deadline = time.monotonic() + 5
+ while time.monotonic() < deadline:
+ with self.server.record_lock:
+ rows = [json.loads(line) for line in self.record.read_text().splitlines()]
+ rows = [row for row in rows if row.get('scope') == scope]
+ if len(rows) >= count:
+ return sorted(rows, key=lambda row: row.get('attempt', 0))
+ time.sleep(0.01)
+ self.fail(f'missing completed request records for {scope}')
+
+ def release(self, scope):
+ with urlopen(Request(self.url + '/_mock/scopes/' + scope, method='DELETE'), timeout=5) as response:
+ return json.load(response)
+
def test_chat_and_model_discovery(self):
with urlopen(self.url + '/v1/models') as response:
models = json.load(response)
@@ -80,28 +120,18 @@ class MockLLMTest(unittest.TestCase):
self.assertEqual(failure.exception.code, 400)
def test_embedding_http_float_base64_and_validation(self):
- def embed(**patch):
- body = {'model': 'mock-embedding', 'input': ['repeatable fixture', 'another fixture'], **patch}
- request = Request(
- self.url + '/v1/embeddings',
- data=json.dumps(body).encode(),
- headers={'Content-Type': 'application/json'},
- )
- with urlopen(request, timeout=5) as response:
- return json.load(response)
-
- first = embed()['data']
- self.assertEqual(first, embed()['data'])
+ first = self.embed()['data']
+ self.assertEqual(first, self.embed()['data'])
self.assertEqual([item['index'] for item in first], [0, 1])
self.assertEqual(len(first[0]['embedding']), 384)
self.assertAlmostEqual(sum(value * value for value in first[0]['embedding']), 1)
- encoded = embed(encoding_format='base64')['data'][0]['embedding']
+ encoded = self.embed(encoding_format='base64')['data'][0]['embedding']
decoded = struct.unpack('<384f', base64.b64decode(encoded))
for actual, expected in zip(decoded, first[0]['embedding']):
self.assertAlmostEqual(actual, expected, places=6)
for patch in ({'input': []}, {'dimensions': 0}, {'encoding_format': 'bad'}, {'model': 'unknown'}):
with self.subTest(patch=patch), self.assertRaises(HTTPError) as caught:
- embed(**patch)
+ self.embed(**patch)
self.assertEqual(caught.exception.code, 400)
def box_turn(self, streaming=False):
@@ -171,6 +201,185 @@ class MockLLMTest(unittest.TestCase):
)
self.assertEqual(response['choices'][0]['message']['content'], 'LANGBOT_RAG_OK')
+ def test_empty_rag_rejects_context_from_another_knowledge_base(self):
+ self.assertIn('LANGBOT_EMPTY_RAG_CHAT_OK', self.post('mock-daily-empty-rag-chat'))
+ for leaked in ('aurora-parser-rag-9137', 'cedar-isolation-4826'):
+ with self.subTest(leaked=leaked), self.assertRaises(HTTPError) as caught:
+ self.post('mock-daily-empty-rag-chat', messages=[{'role': 'user', 'content': leaked}])
+ self.assertEqual(caught.exception.code, 400)
+
+ def test_concurrent_scopes_fail_independently_then_recover(self):
+ def conversation(index):
+ scope = f'concurrent-{index}'
+ body = {'messages': [{'role': 'user', 'content': f'hello LB_MOCK_SCOPE={scope}'}]}
+ with self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', **body)
+ self.assertEqual(caught.exception.code, 503)
+ self.assertIn('LANGBOT_CHAT_OK', self.post())
+ self.assertIn('LANGBOT_RECOVERY_OK', self.post('mock-recover-503', **body))
+ rows = self.records(scope, 2)
+ self.assertEqual([row['attempt'] for row in rows], [1, 2])
+ self.assertEqual(rows[0]['faults'], [{'kind': 'http_error', 'status': 503}])
+ self.assertEqual(rows[1]['faults'], [])
+ self.assertTrue(rows[1]['response_complete'])
+ self.assertEqual(self.release(scope)['released'], 1)
+
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ list(pool.map(conversation, range(8)))
+
+ def test_concurrent_retries_consume_only_one_failure(self):
+ def request(_):
+ try:
+ self.post('mock-recover-503', mock_scope='same-scope')
+ return 200
+ except HTTPError as exc:
+ return exc.code
+
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ statuses = list(pool.map(request, range(8)))
+ self.assertEqual(sorted(statuses), [200] * 7 + [503])
+ rows = self.records('same-scope', 8)
+ self.assertEqual(sorted(row['attempt'] for row in rows), list(range(1, 9)))
+ self.assertEqual(self.release('same-scope')['released'], 1)
+
+ def test_stream_disconnect_is_a_transport_failure_and_recovers(self):
+ with self.assertRaises(IncompleteRead) as caught:
+ self.post('mock-recover-stream-disconnect', stream=True, mock_scope='disconnect')
+ self.assertIn(b'LANGBOT_', caught.exception.partial)
+ self.assertNotIn(b'[DONE]', caught.exception.partial)
+ result = self.post('mock-recover-stream-disconnect', stream=True, mock_scope='disconnect')
+ self.assertIn('data: [DONE]', result)
+ rows = self.records('disconnect', 2)
+ self.assertEqual(rows[0]['faults'], [{'kind': 'stream_disconnect', 'after_chunks': 2}])
+ self.assertFalse(rows[0]['response_complete'])
+ self.assertTrue(rows[0]['injected_disconnect'])
+ self.assertEqual(rows[1]['faults'], [])
+ self.assertTrue(rows[1]['response_complete'])
+ self.release('disconnect')
+
+ def test_delay_and_midstream_stall_timeout_then_recover(self):
+ for model, stream, kind in (
+ ('test-delay-recovery', False, 'delay'),
+ ('test-stall-recovery', True, 'stream_stall'),
+ ):
+ with self.subTest(model=model):
+ with self.assertRaises(TimeoutError):
+ self.post(model, stream=stream, timeout=0.05, mock_scope=model)
+ self.assertIn('LANGBOT_CHAT_OK', self.post())
+ result = self.post(model, stream=stream, mock_scope=model)
+ self.assertIn('data: [DONE]' if stream else 'RECOVERED', result)
+ rows = sorted(self.records(model, 2), key=lambda row: row['attempt'])
+ self.assertEqual(rows[0]['faults'][0]['kind'], kind)
+ self.assertGreaterEqual(rows[0]['faults'][0]['elapsed_seconds'], 0.2)
+ self.assertEqual(rows[1]['faults'], [])
+ self.release(model)
+
+ def test_embedding_errors_timeout_count_and_dimension(self):
+ for status in (429, 503):
+ scope = f'embed-{status}'
+ with self.assertRaises(HTTPError) as caught:
+ self.embed(model=f'mock-embedding-{status}', mock_scope=scope)
+ self.assertEqual(caught.exception.code, status)
+ self.assertEqual(self.records(scope, 1)[0]['faults'], [{'kind': 'http_error', 'status': status}])
+ with self.assertRaises(TimeoutError):
+ self.embed(model='mock-embedding-timeout', mock_scope='embed-timeout', timeout=0.05)
+ self.assertEqual(len(self.embed()['data']), 2)
+ for encoding in ('float', 'base64'):
+ wrong_count = self.embed(model='mock-embedding-wrong-count', encoding_format=encoding)
+ self.assertEqual(len(wrong_count['data']), 1)
+ wrong_dimension = self.embed(
+ model='mock-embedding-wrong-dimension',
+ dimensions=4,
+ encoding_format=encoding,
+ )
+ self.assertEqual([item['index'] for item in wrong_dimension['data']], [0, 1])
+ vector = wrong_dimension['data'][0]['embedding']
+ self.assertEqual(len(vector) if encoding == 'float' else len(base64.b64decode(vector)) // 4, 3)
+ delay = self.records('embed-timeout', 1)[0]['faults'][0]
+ self.assertEqual(delay['kind'], 'delay')
+ self.assertGreaterEqual(delay['elapsed_seconds'], 2)
+
+ def test_embedding_recovery_scope_does_not_modify_vectors(self):
+ expected = self.embed()['data']
+
+ def conversation(scope):
+ with self.assertRaises(HTTPError) as caught:
+ self.embed(model='mock-embedding-recover-503', mock_scope=scope)
+ self.assertEqual(caught.exception.code, 503)
+ result = self.embed(model='mock-embedding-recover-503', mock_scope=scope)
+ self.assertEqual(result['data'], expected)
+ self.assertEqual(self.records(scope, 2)[1]['faults'], [])
+ self.release(scope)
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ list(pool.map(conversation, ('embed-a', 'embed-b')))
+
+ def test_markers_select_current_scope_and_invalid_input_does_not_advance(self):
+ with self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', messages=[], mock_scope='marker-current')
+ self.assertEqual(caught.exception.code, 400)
+ with self.assertRaises(HTTPError) as caught:
+ self.post(
+ 'mock-recover-503',
+ messages=[
+ {'role': 'user', 'content': 'LB_MOCK_SCOPE=marker-old'},
+ {'role': 'assistant', 'content': 'prior reply'},
+ {'role': 'user', 'content': [{'type': 'text', 'text': 'LB_MOCK_SCOPE=marker-current'}]},
+ ],
+ )
+ self.assertEqual(caught.exception.code, 503)
+ self.assertEqual(self.records('marker-current', 1)[0]['attempt'], 1)
+ self.assertIn('LANGBOT_RECOVERY_OK', self.post('mock-recover-503', mock_scope='marker-current'))
+ self.assertEqual(self.release('marker-current')['released'], 1)
+ self.assertEqual(self.release('marker-old')['released'], 0)
+ with self.assertRaises(HTTPError) as caught:
+ self.embed(model='mock-embedding-recover-503', input=['LB_MOCK_SCOPE=marker-embed'])
+ self.assertEqual(caught.exception.code, 503)
+ self.assertEqual(self.records('marker-embed', 1)[0]['attempt'], 1)
+ self.release('marker-embed')
+
+ def test_scope_validation_capacity_expiry_and_cleanup(self):
+ for scope in (None, '', [], 'x' * 129, 'bad/scope'):
+ with self.subTest(scope=scope), self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', mock_scope=scope)
+ self.assertEqual(caught.exception.code, 400)
+ with patch.object(mock_llm, 'MAX_FAULT_SCOPES', len(self.server.fault_scopes) + 1):
+ with self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', mock_scope='capacity-a')
+ self.assertEqual(caught.exception.code, 503)
+ with self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', mock_scope='capacity-b')
+ self.assertEqual(caught.exception.code, 400)
+ self.assertIn('scope limit', caught.exception.read().decode())
+ self.assertIn('LANGBOT_RECOVERY_OK', self.post('mock-recover-503', mock_scope='capacity-a'))
+ self.assertEqual(self.release('capacity-a')['released'], 1)
+ with self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', mock_scope='capacity-b')
+ self.assertEqual(caught.exception.code, 503)
+ with patch.object(mock_llm, 'FAULT_SCOPE_TTL_SECONDS', 0), self.assertRaises(HTTPError) as caught:
+ self.post('mock-recover-503', mock_scope='capacity-b')
+ self.assertEqual(caught.exception.code, 503)
+ self.assertEqual(self.release('capacity-b')['released'], 1)
+ self.assertEqual(self.release('capacity-b')['released'], 0)
+
+ def test_scenario_controls_reject_invalid_values(self):
+ for control in (
+ {'delay_seconds': 61},
+ {'delay_seconds': float('nan')},
+ {'delay_seconds': True},
+ {'recover_after': 0},
+ {'disconnect_after_chunks': -1},
+ {'stall_after_chunks': 2},
+ {'stall_seconds': 1},
+ {'embedding_fault': 'count'},
+ {'forbid_prompt': 'not-an-array'},
+ ):
+ scenario = {'steps': [{'content': 'OK'}], **control}
+ if 'forbid_prompt' in control:
+ scenario = {'steps': [{'content': 'OK', **control}]}
+ with self.subTest(control=control), self.assertRaises(ValueError):
+ validate_scenarios({'invalid': scenario})
+
def test_errors_and_invalid_requests(self):
for status in (401, 429, 503):
with self.subTest(status=status), self.assertRaises(HTTPError) as caught:
diff --git a/tools/test_regression_coverage.py b/tools/test_regression_coverage.py
new file mode 100644
index 000000000..99d6f324b
--- /dev/null
+++ b/tools/test_regression_coverage.py
@@ -0,0 +1,276 @@
+"""Coverage must measure evaluated obligations, not green request counters."""
+
+import copy
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from regression_coverage import checked, collect, http_observations, load_catalog, report, route_inventory, write_report
+
+
+def catalog():
+ return {
+ 'version': 1,
+ 'scope': 'Test scope',
+ 'contracts': [
+ {
+ 'id': 'reply',
+ 'area': 'conversation',
+ 'kind': 'blackbox',
+ 'priority': 'P0',
+ 'description': 'Reply and payload agree',
+ 'bindings': [{'profile': 'smoke', 'case': 'chat', 'checks': ['response', 'fields']}],
+ },
+ {
+ 'id': 'terminal',
+ 'area': 'execution',
+ 'kind': 'graybox',
+ 'priority': 'P0',
+ 'description': 'Persisted terminal state',
+ 'bindings': [{'profile': 'smoke', 'case': 'chat', 'checks': ['ledger']}],
+ },
+ {
+ 'id': 'recovery',
+ 'area': 'execution',
+ 'kind': 'blackbox',
+ 'priority': 'P0',
+ 'description': 'Restart recovery',
+ 'bindings': [],
+ },
+ ],
+ }
+
+
+def row(**overrides):
+ return {'profile': 'smoke', 'case': 'chat', 'status': 'passed', **overrides}
+
+
+class CoverageTest(unittest.TestCase):
+ def test_old_green_rows_and_transport_failures_are_not_coverage(self):
+ for r in [row(), row(status='unknown'), row(assertions={'response': 'passed'})]:
+ data = report(catalog(), [r])
+ self.assertEqual(data['kinds']['blackbox']['decided'], 0)
+ self.assertEqual(data['contracts'][0]['status'], 'incomplete')
+ r = {}
+ with self.assertRaises(TimeoutError):
+ checked(r, 'response', lambda: (_ for _ in ()).throw(TimeoutError('transport')))
+ self.assertNotIn('response', r['assertions'])
+
+ def test_a_failed_assertion_is_covered_but_not_passed(self):
+ r = row()
+ with self.assertRaises(ValueError):
+ checked(r, 'response', lambda: (_ for _ in ()).throw(ValueError('wrong reply')))
+ data = report(catalog(), [r])
+ self.assertEqual(data['kinds']['blackbox']['coverage_percent'], 50)
+ self.assertEqual(data['kinds']['blackbox']['pass_percent'], 0)
+ self.assertEqual(data['contracts'][0]['status'], 'failed')
+
+ def test_graybox_failure_does_not_erase_external_assertions(self):
+ data = report(
+ catalog(),
+ [
+ row(
+ status='failed',
+ assertions={
+ 'response': 'passed',
+ 'fields': 'passed',
+ 'ledger': 'failed',
+ },
+ )
+ ],
+ )
+ self.assertEqual(data['contracts'][0]['status'], 'passed')
+ self.assertEqual(data['contracts'][1]['status'], 'failed')
+ self.assertEqual(data['contracts'][2]['status'], 'missing_test')
+
+ def test_repetition_does_not_inflate_coverage_or_hide_incomplete_turns(self):
+ good = row(assertions={'response': 'passed', 'fields': 'passed'})
+ self.assertEqual(report(catalog(), [good])['kinds'], report(catalog(), [good] * 100)['kinds'])
+ mixed = report(catalog(), [good, row(status='unknown')])
+ self.assertEqual(mixed['contracts'][0]['status'], 'incomplete')
+ self.assertEqual(report(catalog(), [row(status='skipped')])['contracts'][0]['status'], 'skipped')
+ self.assertEqual(report(catalog(), [])['contracts'][0]['status'], 'not_run')
+
+ def test_first_turn_does_not_prove_history_and_unclassified_cases_are_visible(self):
+ c = catalog()
+ c['contracts'][0]['bindings'][0]['min_turn'] = 2
+ r = row(turn=1, assertions={'response': 'passed', 'fields': 'passed'})
+ data = report(c, [r, row(case='new_case')])
+ self.assertEqual(data['contracts'][0]['status'], 'not_run')
+ self.assertEqual(data['unclassified_cases'], [{'profile': 'smoke', 'case': 'new_case'}])
+
+ def test_partial_evidence_produces_an_error_and_a_report(self):
+ with tempfile.TemporaryDirectory() as d:
+ root = Path(d)
+ (root / 'profile.json').write_text(json.dumps({'coverage_suite': 'smoke', 'smoke': {}}))
+ (root / 'cases.json').write_text('[{"case":')
+ rows, errors = collect(root)
+ self.assertEqual(rows, [])
+ self.assertEqual(len(errors), 1)
+ result = write_report([root], root)
+ self.assertTrue(result['evidence_errors'])
+ self.assertEqual(result['kinds']['blackbox']['decided'], 0)
+ self.assertTrue((root / 'coverage.md').is_file())
+
+ def test_missing_phase_evidence_cannot_hide_behind_a_successful_phase(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / 'profile.json').write_text(
+ json.dumps(
+ {
+ 'coverage_suite': 'smoke',
+ 'phases': [{'name': 'first'}, {'name': 'second'}],
+ }
+ )
+ )
+ (root / 'first').mkdir()
+ (root / 'first/requests.jsonl').write_text(
+ json.dumps(
+ row(
+ assertions={'response': 'passed', 'fields': 'passed'},
+ )
+ )
+ + '\n'
+ )
+ rows, errors = collect(root)
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(len(errors), 1)
+ self.assertFalse(report(catalog(), rows, errors)['complete_evidence'])
+
+ def test_empty_and_absent_planned_cases_are_explicit_evidence_errors(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ (root / 'profile.json').write_text(
+ json.dumps(
+ {
+ 'coverage_suite': 'smoke',
+ 'smoke': {},
+ 'cases': [{'name': 'chat'}],
+ }
+ )
+ )
+ (root / 'cases.json').write_text('[]')
+ rows, errors = collect(root)
+ self.assertEqual(rows, [])
+ self.assertTrue(any('empty' in error for error in errors))
+ self.assertTrue(any('planned cases' in error for error in errors))
+
+ def test_http_hits_use_method_templates_and_do_not_retain_private_urls(self):
+ inventory = {
+ 'routes': [
+ {'route': value}
+ for value in (
+ 'GET /things/',
+ 'GET /things/new',
+ 'POST /things/',
+ 'POST /bots//',
+ )
+ ]
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / 'access.log'
+ path.write_text(
+ '\n'.join(
+ [
+ 'GET /things/new?token=secret 1.1 200',
+ 'GET /things/private-id 1.1 404',
+ 'POST /things/private-id 1.1 403',
+ 'POST /bots/private-bot/reset 1.1 200',
+ 'GET /unknown/private-id 1.1 404',
+ 'application log is not a request',
+ ]
+ )
+ )
+ result = http_observations([path], inventory)
+ self.assertEqual(result['records'], 5)
+ self.assertEqual(len(result['routes']), 4)
+ self.assertEqual(result['unclassified'], {'GET 404': 1})
+ encoded = json.dumps(result)
+ for value in ('private-id', 'private-bot', 'secret'):
+ self.assertNotIn(value, encoded)
+ self.assertTrue(http_observations([path.with_name('missing')], inventory)['evidence_errors'])
+
+ def test_http_converter_and_invalid_url_do_not_create_false_hits_or_leaks(self):
+ inventory = {'routes': [{'route': 'GET /keys/'}]}
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / 'access.log'
+ path.write_text(
+ '\n'.join(
+ [
+ 'GET /keys/not-an-int 1.1 404',
+ 'GET http://[private-tenant]/?token=secret 1.1 400',
+ 'GET /keys/42 1.1 200',
+ ]
+ )
+ )
+ result = http_observations([path], inventory)
+ self.assertEqual(result['routes'], [{'route': 'GET /keys/', 'statuses': {'200': 1}}])
+ self.assertEqual(result['unclassified'], {'GET 404': 1, 'GET 400': 1})
+ self.assertEqual(len(result['evidence_errors']), 1)
+ self.assertNotIn('private-tenant', json.dumps(result))
+ self.assertNotIn('secret', json.dumps(result))
+
+ def test_suite_finalization_rejects_missing_evidence_for_direct_callers(self):
+ from regression_suite import Suite
+
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ profile = {
+ 'version': 1,
+ 'coverage_suite': 'smoke',
+ 'smoke': {},
+ 'cli': {'config': 'unused'},
+ 'cases': [{'name': 'chat'}],
+ }
+ suite = Suite(profile, root / 'input.json', root)
+ suite.write('profile.json', profile)
+ result = {'errors': []}
+ suite.finish(result)
+ self.assertFalse(result['passed'])
+ self.assertTrue((root / 'coverage.json').exists())
+ self.assertFalse(json.loads((root / 'summary.json').read_text())['passed'])
+
+ def test_new_routes_are_discovered_without_adding_test_cases(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source = root / 'src/langbot/pkg/example.py'
+ source.parent.mkdir(parents=True)
+ source.write_text("""
+@group.group_class('example', '/api/example')
+class Routes:
+ async def initialize(self):
+ @self.route('/new', methods=['POST'])
+ async def new_operation(): pass
+ @self.route(dynamic_path)
+ async def dynamic_operation(): pass
+""")
+ inventory = route_inventory(catalog(), root)
+ self.assertEqual(inventory['routes'][0]['route'], 'POST /api/example/new')
+ self.assertEqual(inventory['routes'][0]['contracts'], [])
+ self.assertEqual(len(inventory['unresolved']), 1)
+
+ def test_catalog_bindings_name_real_cases_and_required_checks(self):
+ c = load_catalog()
+ profiles = {}
+ routes = {r['route'] for r in route_inventory(c)['routes']}
+ for contract in c['contracts']:
+ self.assertTrue(set(contract.get('routes', [])) <= routes)
+ for binding in contract['bindings']:
+ name = binding['profile']
+ if name not in profiles:
+ path = Path(__file__).with_name('regression_profiles') / f'{name}.json'
+ profiles[name] = {x['name']: x for x in json.loads(path.read_text())['cases']}
+ self.assertIn(binding['case'], profiles[name])
+ self.assertTrue(binding['checks'])
+ broken = copy.deepcopy(c)
+ broken['contracts'].append(broken['contracts'][0])
+ with tempfile.TemporaryDirectory() as d:
+ path = Path(d) / 'catalog.json'
+ path.write_text(json.dumps(broken))
+ with self.assertRaises(ValueError):
+ load_catalog(path)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/test_regression_job.py b/tools/test_regression_job.py
index 774eb5964..40f75ad32 100644
--- a/tools/test_regression_job.py
+++ b/tools/test_regression_job.py
@@ -13,7 +13,7 @@ from unittest.mock import patch
import yaml
-from regression_job import Job, main
+from regression_job import Job, load_profile, main
from test_regression_suite import FAKE_CLI, profile
@@ -89,6 +89,36 @@ class ControlledJob(Job):
class JobTests(unittest.TestCase):
+ def test_published_resolution_failure_is_preparation_failure_without_deployment(self):
+ with tempfile.TemporaryDirectory() as folder:
+ root = Path(folder)
+ job = ControlledJob({}, profile(), root / 'profile.json', root / 'run', 'unused')
+ with patch('regression_job.prepare_published_plugins', side_effect=RuntimeError('release unavailable')):
+ result = job.run()
+ self.assertFalse(result['passed'])
+ self.assertFalse(result['completed'])
+ self.assertEqual(result['phase'], 'plugins')
+ self.assertEqual(result['errors'], ['release unavailable'])
+ self.assertEqual(job.calls, [])
+
+ def test_job_defaults_to_published_plugins_and_keeps_explicit_profile_mode(self):
+ with tempfile.TemporaryDirectory() as folder:
+ root = Path(folder)
+ suite = profile()
+ suite['plugins'] = [{'source': 'local', 'file': 'developer.zip'}]
+ (root / 'suite.json').write_text(json.dumps(suite))
+ path = root / 'job.json'
+ manifest = {'version': 1, 'suite': 'suite.json', 'deployment': {'version': 'v4.11.0'}}
+ path.write_text(json.dumps(manifest))
+ self.assertEqual(load_profile(path)[1]['plugins'], [])
+ manifest['plugin_mode'] = 'profile'
+ path.write_text(json.dumps(manifest))
+ self.assertEqual(load_profile(path)[1]['plugins'], suite['plugins'])
+ manifest['plugin_manifest'] = 'previous/manifest.json'
+ path.write_text(json.dumps(manifest))
+ with self.assertRaisesRegex(ValueError, 'requires published'):
+ load_profile(path)
+
def test_lifecycle_success_and_partial_install_recovery(self):
class Health(BaseHTTPRequestHandler):
def do_GET(self):
@@ -108,8 +138,13 @@ class JobTests(unittest.TestCase):
cli = root / 'lbctl-test'
cli.write_text(f'#!{sys.executable}\n' + FAKE_CLI)
cli.chmod(0o700)
- manifest = {'lbctl': str(cli), 'deployment': {'version': 'v4.11.0', 'port': 15351}}
+ manifest = {
+ 'lbctl': str(cli),
+ 'deployment': {'version': 'v4.11.0', 'port': 15351},
+ 'plugin_mode': 'profile',
+ }
suite = profile()
+ suite['coverage_suite'] = 'smoke'
suite['resources'] = [{'id': 'chat', 'kind': 'pipeline', 'body': {'name': 'QA', 'config': {'ai': {}}}}]
suite['cases'][0].update(args=['pipeline', 'run', '${chat}'], expect='TEST_OK')
suite['phases'][0].update(duration_seconds=0.5, rps=4, workers=2)
@@ -117,6 +152,7 @@ class JobTests(unittest.TestCase):
job = ControlledJob(manifest, suite, root / 'profile.json', root / 'success', health)
result = job.run()
self.assertTrue(result['passed'], result)
+ self.assertTrue((job.output / 'suite/coverage.json').exists())
self.assertEqual(result['phase'], 'cleaned')
self.assertFalse((job.deploy / 'data').exists())
self.assertFalse((job.private / 'api-key').exists())
diff --git a/tools/test_regression_plugins.py b/tools/test_regression_plugins.py
new file mode 100644
index 000000000..c8c3bcf3d
--- /dev/null
+++ b/tools/test_regression_plugins.py
@@ -0,0 +1,192 @@
+"""Exercise published resolution, replay integrity and installation readback."""
+
+from collections import Counter
+import copy
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import io
+import json
+from pathlib import Path
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+import zipfile
+
+import regression_plugins as plugins
+
+
+def archive(plugin_id, version='1.2.3'):
+ author, name = plugin_id.split('/')
+ output = io.BytesIO()
+ with zipfile.ZipFile(output, 'w') as package:
+ package.writestr(
+ zipfile.ZipInfo('manifest.yaml'),
+ f'kind: Plugin\nmetadata:\n author: {author}\n name: {name}\n version: {version}\n',
+ )
+ package.writestr(zipfile.ZipInfo('main.py'), '# Published fixture\n')
+ return output.getvalue()
+
+
+class PublishedPluginsTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ class Marketplace(BaseHTTPRequestHandler):
+ def do_GET(self):
+ cls.requests.append(self.path)
+ status, body = cls.responses.get(self.path, (404, b'missing published release'))
+ self.send_response(status)
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args):
+ pass
+
+ cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Marketplace)
+ cls.thread = threading.Thread(target=cls.server.serve_forever)
+ cls.thread.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+ cls.server.server_close()
+ cls.thread.join()
+
+ def setUp(self):
+ self.folder = tempfile.TemporaryDirectory()
+ self.addCleanup(self.folder.cleanup)
+ self.root = Path(self.folder.name)
+ self.marketplace = patch.object(plugins, 'MARKETPLACE', f'http://127.0.0.1:{self.server.server_port}')
+ self.marketplace.start()
+ self.addCleanup(self.marketplace.stop)
+ type(self).requests = []
+ type(self).responses = {}
+ for plugin_id in plugins.PLUGIN_IDS:
+ author, name = plugin_id.split('/')
+ data = {
+ 'code': 0,
+ 'data': {
+ 'plugin': {
+ 'plugin_id': plugin_id,
+ 'author': author,
+ 'name': name,
+ 'latest_version': '1.2.3',
+ 'status': 'live',
+ }
+ },
+ }
+ self.responses[f'/api/v1/marketplace/plugins/{plugin_id}'] = (200, json.dumps(data).encode())
+ self.responses[f'/api/v1/marketplace/plugins/download/{plugin_id}/1.2.3'] = (200, archive(plugin_id))
+
+ def test_latest_resolved_once_and_replay_uses_same_published_bytes(self):
+ directory = self.root / 'latest'
+ manifest = plugins.prepare_published_plugins(directory)
+ self.assertEqual(Counter(self.requests), {path: 1 for path in self.responses})
+ installed = []
+
+ def call(args):
+ if args[:3] == ['plugin', 'install', 'local']:
+ source = Path(args[4])
+ self.assertEqual(source.read_bytes(), archive(plugins.PLUGIN_IDS[len(installed)]))
+ author, name = plugins.PLUGIN_IDS[len(installed)].split('/')
+ installed.append(
+ {'author': author, 'name': name, 'version': '1.2.3', 'enabled': True, 'status': 'initialized'}
+ )
+ return {}
+ self.assertEqual(args, ['plugin', 'list'])
+ return {'plugins': installed}
+
+ plugins.install_published_plugins(call, directory, manifest)
+ recorded = json.loads((directory / 'manifest.json').read_text())
+ self.assertTrue(all(entry['installed'][0]['status'] == 'initialized' for entry in recorded['plugins']))
+ self.requests.clear()
+ replay = plugins.prepare_published_plugins(self.root / 'replay', directory / 'manifest.json')
+ self.assertEqual(
+ [entry['sha256'] for entry in replay['plugins']], [entry['sha256'] for entry in manifest['plugins']]
+ )
+ self.assertEqual(len(self.requests), 3)
+ self.assertTrue(all('/download/' in path for path in self.requests))
+
+ def test_bad_metadata_and_unpublished_versions_fail_without_download(self):
+ url = '/api/v1/marketplace/plugins/langbot-team/LocalAgent'
+ valid = json.loads(self.responses[url][1])
+ invalid = [[], {'code': 0, 'data': []}, {'code': 1, 'data': valid['data']}]
+ for field, value in [
+ ('latest_version', ''),
+ ('latest_version', '../bad'),
+ ('status', 'draft'),
+ ('name', 'Other'),
+ ]:
+ data = copy.deepcopy(valid)
+ data['data']['plugin'][field] = value
+ invalid.append(data)
+ for index, payload in enumerate(invalid):
+ with self.subTest(payload=payload):
+ self.requests.clear()
+ self.responses[url] = (200, json.dumps(payload).encode())
+ with self.assertRaises(ValueError):
+ plugins.prepare_published_plugins(self.root / str(index))
+ self.assertEqual(self.requests, [url])
+
+ def test_missing_or_wrong_release_does_not_fall_back(self):
+ url = '/api/v1/marketplace/plugins/download/langbot-team/LocalAgent/1.2.3'
+ self.responses[url] = (404, b'missing')
+ with self.assertRaisesRegex(RuntimeError, 'HTTP Error 404'):
+ plugins.prepare_published_plugins(self.root / 'missing')
+ self.responses[url] = (200, archive('langbot-team/LocalAgent', '0.0.1'))
+ with self.assertRaisesRegex(ValueError, 'identity/version differs'):
+ plugins.prepare_published_plugins(self.root / 'wrong')
+
+ def test_replay_rejects_changed_packages_and_untrusted_identity(self):
+ directory = self.root / 'latest'
+ manifest = plugins.prepare_published_plugins(directory)
+ replay = directory / 'manifest.json'
+ url = '/api/v1/marketplace/plugins/download/langbot-team/LocalAgent/1.2.3'
+ self.responses[url] = (200, b'different package')
+ with self.assertRaisesRegex(ValueError, 'sha256 changed'):
+ plugins.prepare_published_plugins(self.root / 'changed', replay)
+ for change in ('identity', 'duplicate', 'origin', 'hash'):
+ invalid = copy.deepcopy(manifest)
+ if change == 'identity':
+ invalid['plugins'][0]['id'] = 'untrusted/Other'
+ elif change == 'duplicate':
+ invalid['plugins'][1] = invalid['plugins'][0]
+ elif change == 'origin':
+ invalid['marketplace'] = 'https://untrusted.example'
+ else:
+ del invalid['plugins'][0]['sha256']
+ replay.write_text(json.dumps(invalid))
+ with self.subTest(change=change), self.assertRaises(ValueError):
+ plugins.prepare_published_plugins(self.root / change, replay)
+
+ def test_readback_records_actual_failed_version_and_stops_installing(self):
+ directory = self.root / 'latest'
+ manifest = plugins.prepare_published_plugins(directory)
+ calls = []
+
+ def call(args):
+ calls.append(args)
+ return {
+ 'plugins': [
+ {
+ 'author': 'langbot-team',
+ 'name': 'LocalAgent',
+ 'version': '0.0.1',
+ 'enabled': True,
+ 'status': 'initialized',
+ }
+ ]
+ }
+
+ with self.assertRaisesRegex(RuntimeError, 'not ready at the selected version'):
+ plugins.install_published_plugins(call, directory, manifest)
+ self.assertEqual(len(calls), 2)
+ recorded = json.loads((directory / 'manifest.json').read_text())
+ self.assertEqual(recorded['plugins'][0]['installed'][0]['version'], '0.0.1')
+ (directory / manifest['plugins'][0]['archive']).write_bytes(b'changed after download')
+ with self.assertRaisesRegex(ValueError, 'changed before installation'):
+ plugins.install_published_plugins(call, directory, manifest)
+ self.assertEqual(len(calls), 2)
+
+
+if __name__ == '__main__':
+ unittest.main()