Compare commits

..

41 Commits

Author SHA1 Message Date
Hyu 1b096167f4 style: format valkey error message 2026-07-13 00:17:51 +08:00
Hyu 578565dbe2 fix: skip valkey-glide on Windows 2026-07-13 00:11:34 +08:00
Guanchao Wang 2a3f7f8059 fix(mcp): harden remote SSE fallback (#2324)
* fix(mcp): harden remote SSE fallback

* fix(mcp): preserve transport errors during fallback

---------

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-07-11 22:08:37 +08:00
RockChinQ 940234a0d8 docs: update blog links to main site 2026-07-09 14:13:04 -04:00
Daria Korenieva 0c405901d2 feat(vector): add Valkey Search vector database backend (#2276)
* feat(vector): add Valkey Search vector database backend

Add a new opt-in VectorDatabase backend backed by the Valkey Search module
(valkey/valkey-bundle), accessed via the official valkey-glide client's native
ft command namespace.

- Implements the full VectorDatabase ABC: VECTOR, FULL_TEXT and HYBRID search,
  all 8 metadata filter operators, and pagination with exact totals.
- HYBRID uses filter-then-KNN (no app-side weighted fusion); vector_weight is
  accepted for interface parity but NOT honored (docstring + one-time warning +
  docs caveat).
- Lazy connect so a down Valkey never blocks boot; mandatory
  client_name=langbot_vector_client; optional auth + TLS (never logged).
- Registered via a single elif branch in vector/mgr.py; disabled by default
  (vdb.use stays chroma) for toC compatibility.
- Adds valkey-glide>=2.4.1,<3.0.0; no protobuf/pydantic downgrade; no ORM
  change so no Alembic migration.
- Unit tests (fast lane, no server) + slow-gated integration tests
  (TEST_VALKEY_URL, valkey/valkey-bundle:9.1.0) + integration doc.

* fix(vector): paginate Valkey Search deletes and guard delete_by_filter

Address self-review follow-ups for the Valkey Search VDB backend:

- _search_keys now paginates through the full result set in batches of
  _DELETE_SCAN_BATCH instead of capping at a single hard-coded 10000-key
  page, so delete_by_file_id / delete_by_filter fully remove files and
  filters that match more than one page of chunks (no orphaned vectors).
- Add unit regression tests for the delete_by_filter mass-deletion guard:
  a filter referencing only non-indexed fields must skip and return 0
  (never fall back to match-all), and a supported filter still deletes
  matching keys.

* refactor(vector): harden Valkey Search backend and add adversarial tests

Address the self-review NICE-TO-HAVE items for the Valkey Search VDB backend:
- Guard the username-without-password credential edge (skip auth + warn
  instead of building ServerCredentials(password=None, ...), which glide
  rejects).
- Add an async close() teardown that closes the glide client and resets
  cached state (re-init is safe via the existing None guard).
- Hoist 'import json' to module top (was imported inside three methods).
- Document the FT TAG literal-brace limitation in _escape_tag (fails closed,
  never widens).

Tests:
- Add an adversarial-input integration test proving crafted file_id /
  query_text cannot break out of or widen a query (fail-closed on braces).
- Add unit tests for close() and the credential-build guard.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* fix(vector): make Valkey Search file_id TAG support arbitrary characters

Valkey Search's FT TAG query parser cannot handle '{', '}' or '*' even when
backslash-escaped, so a file_id containing those characters previously
produced an unparseable query (it failed closed / raised). Percent-encode
exactly those FT-unsafe characters (plus '%' for reversibility) in the
file_id TAG value, applied identically at write time and query time, so an
arbitrary file_id round-trips. For normal UUID/hash ids this is a no-op and
the stored value is unchanged; the original file_id is always preserved
verbatim in metadata_json.

Strengthen the adversarial integration test to assert a brace/star-bearing
file_id matches and deletes exactly its own row (no widening, no raise), and
add unit tests for _encode_file_id and the filter encoding.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* refactor(vector): address Valkey Search review feedback

- Add configurable request_timeout (default 5000ms; glide default 250ms is
  too low for KNN); expose in config.yaml + docs table
- Validate embedding dimension consistency in add_embeddings (fail fast on
  mixed lengths to avoid silent KNN corruption)
- Use ft.info (O(1)) instead of ft.list (O(n)) for index existence checks in
  the query hot path; also closes the check-then-create TOCTOU window
- Pipeline HSETs via a non-atomic Batch instead of N sequential awaits
- Extract shared _iter_reply_docs to deduplicate reply parsing between
  _reply_to_chroma and list_by_filter
- Parenthesize multi-condition pre-filters before the => KNN clause
- Fail closed when a username is configured without a password
- Catch only RequestError on ft.dropindex (let connection/auth errors surface)
- Bound the delete_collection SCAN loop with a safety cap
- Add VectorDatabase.close() (no-op default) + VectorDBManager.shutdown()
- Simplify _MATCH_ALL literal; normalize typing to builtin generics

* fix(vector/valkey_search): address round-2 review feedback

- Serialize lazy client creation with an asyncio.Lock (double-checked) so
  concurrent first-use callers don't construct and leak duplicate clients.
- Make the filter operator chain exhaustive: raise on an unhandled op rather
  than silently dropping the condition (which could widen delete_by_filter).
- Cast numeric range (///) values to float, failing closed on
  non-numeric input and pre-empting a future NUMERIC-field injection surface.

* refactor(vector): remove shutdown/close from base ABC per maintainer feedback Per maintainer request, interface changes to VectorDatabase ABC and VectorDBManager should be in a separate PR with implementation across all backends. The ValkeySearchVectorDatabase.close() method remains but does not override an ABC method.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

* docs(test): list valkey_search in vdb coverage exclusions Add valkey_search to the documented vector/vdbs/ coverage-exclusion list, matching the existing chroma/milvus/pgvector/qdrant/seekdb entries. These adapters require a live database instance and are covered by env-gated integration tests instead of unit tests.

Signed-off-by: Daria Korenieva <daric2612@gmail.com>

---------

Signed-off-by: Daria Korenieva <daric2612@gmail.com>
2026-07-08 06:59:16 +08:00
Hyu 00e2103873 docs(config): add box.default_memory_mb to config.yaml template (#2319)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 15:18:27 +08:00
Hyu 209706b0b9 feat(mcp): add box.default_memory_mb config for nsjail memory limit (#2318)
Operators can now set a global default memory limit for all stdio MCP
servers in config.yaml or via environment variable:

  config.yaml:
    box:
      default_memory_mb: 2048  # default: 1536

  env:
    BOX__DEFAULT_MEMORY_MB=2048

The default is raised from 1024 to 1536 MB — a safer floor for
Node.js V8 + WASM (undici llhttp) under nsjail cgroup limits.
Individual MCP servers can still override via their own box.memory_mb.

Previously the fallback was hardcoded to 1024 MB, causing OOM kills
(return_code=137) on node/npx MCP servers that need more RAM.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 14:24:06 +08:00
Hyu 205404e3da fix(deps): pin langbot-plugin 0.4.13 (memory_mb removed from _COMPAT_FIELDS) (#2317)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 13:45:13 +08:00
Hyu 3e93ccfb45 fix(mcp): use uniform session memory_mb=1024 to avoid BoxSessionConflictError (#2316)
All MCPs share one Box session (mcp-shared). When session memory_mb differed
by command type (512 for python, 1024 for node), the second MCP to call
create_session raised BoxSessionConflictError. Fix: always use 1024 MB for
the shared session so python and node MCPs coexist without conflict.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:55:49 +08:00
Hyu c5d7e3dcb1 fix(deps): pin langbot-plugin 0.4.12 (BoxManagedProcessSpec.memory_mb fix) (#2315)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:31:09 +08:00
Hyu cd6d5d9c2f fix(mcp): pin langbot-plugin 0.4.10 - per-process memory_mb for node OOM fix (#2314)
* fix(mcp): bump default memory to 1024MB for node (npx) stdio MCP servers

Node.js MCP servers (npx/bunx) were being OOM-killed (return_code=137) by the
default 512MB nsjail cgroup_mem_max. Node V8 reserves large virtual address
space and instantiates WebAssembly modules (undici llhttp) on startup, easily
exceeding 512MB resident. This caused every node-based MCP (memory,
sequential-thinking, filesystem, weather, docker, excel) to crash-loop.

Fix: when the stdio command is npx/bunx/pnpm, default memory_mb to 1024 unless
the operator explicitly set a value. Python/uvx servers keep the 512MB default.

* chore(deps): pin langbot-plugin 0.4.10 (per-process memory_mb fix)

* chore: update uv.lock for langbot-plugin 0.4.10

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 09:03:43 +08:00
Hyu 1f7d9339fc fix(mcp): bump default memory to 1024MB for node (npx) stdio MCP servers (#2313)
Node.js MCP servers (npx/bunx) were being OOM-killed (return_code=137) by the
default 512MB nsjail cgroup_mem_max. Node V8 reserves large virtual address
space and instantiates WebAssembly modules (undici llhttp) on startup, easily
exceeding 512MB resident. This caused every node-based MCP (memory,
sequential-thinking, filesystem, weather, docker, excel) to crash-loop.

Fix: when the stdio command is npx/bunx/pnpm, default memory_mb to 1024 unless
the operator explicitly set a value. Python/uvx servers keep the 512MB default.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-04 08:31:08 +08:00
RockChinQ f3b5fcfb7c fix mcp sidebar status sync (#2312)
Co-authored-by: Hyu <chenhyu@proton.me>
2026-07-03 20:23:12 +08:00
Hyu ffabb91bfe fix(mcp): add missing MCPLogs import to MCPForm (#2311)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 17:59:02 +08:00
Hyu 96c84740db feat(mcp): add logs tab to MCP server detail panel (frontend) (#2310)
* feat(mcp): add logs tab to MCP server detail panel

- Add MCPLogs component mirroring PluginLogs functionality
- Add getMcpServerLogs API method to BackendClient
- Add logs tab to MCPForm detail panel (edit mode only)
- Add i18n keys (tabLogs, logsLevelAll, logsRefresh, logsAutoRefresh, logsEmpty) to all 8 locales
- Auto-refresh logs every 3s with toggle
- Filter logs by level (ALL/DEBUG/INFO/WARNING/ERROR)

* style: fix prettier formatting in MCPForm.tsx logs tab

* style: fix prettier in i18n locale files

---------

Co-authored-by: dadachann <dadachann@users.noreply.github.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 16:52:15 +08:00
Hyu 85b5b5b54b feat(mcp): add MCP server log panel backend (#2308)
- Add log buffer (_log_buffer: deque with maxlen=500) to RuntimeMCPSession
- Capture stderr from Box managed process in monitor_process_health loop
- Add get_mcp_server_logs service method with limit and level filtering
- Add GET /servers/<server_name>/logs HTTP endpoint
- Parse log level from stderr lines (error/warning/debug/info)
- Tests passing: 211 passed, 12 skipped

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 16:05:23 +08:00
Hyu 28bffdef21 fix(mcp): set _preserve_managed_process before finally in cold-start path (#2309)
_ColdStartRetry was caught in _lifecycle_loop_with_retry which set
_preserve_managed_process = True — but by then the finally block inside
_lifecycle_loop had already run and called _cleanup_box_stdio_session(),
stopping the live managed process (return_code=143 SIGTERM). The cold-start
retry then restarted a fresh process, eliminating the warm-up advantage.

Fix: add an explicit except _ColdStartRetry in _lifecycle_loop that sets
_preserve_managed_process = True before re-raising. The finally block then
sees the flag and skips stop_managed_process, leaving the live process
untouched for the next handshake attempt.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 15:26:18 +08:00
Hyu bf3c96026b fix(mcp): fix cold-start connection drop by using lexically-intact owner exit stack (#2307)
The previous _TransferredStack approach broke anyio lexical context:
websocket_client/ClientSession use anyio task groups whose cancel scope is
bound to the frame that entered them. Deferring their aclose via a transferred
exit stack left the underlying memory streams closed once initialize() returned,
so the very next request (refresh -> list_tools) failed with Connection closed.

New design:
- Attach on the owner exit stack (same task as the serve loop, lexically intact)
- A cold-starting process makes initialize() fail; signal _ColdStartRetry up to
  the outer retry loop, which reuses the live process without consuming retry budget
- _lifecycle_loop_with_retry handles _ColdStartRetry like _TransportReconnect:
  preserves process, no fatal budget, backs off 2s and retries
- Two new unit tests: cold-start raises _ColdStartRetry (not fatal) when process
  is alive; raises fatal error when process has actually exited

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 14:58:40 +08:00
Hyu ed9343c686 fix(mcp): retry the stdio handshake during slow (npx) cold starts (#2306)
A node/npx stdio MCP server (e.g. firecrawl-mcp via npx -y) failed on first
connect with Connection closed / Failed after 4 attempts, even though the
process was fine. An npx cold start downloads+installs the package before the
server can answer the MCP handshake (measured ~27s for a simple official
server; longer for heavier ones). The old code attached the WS and called
session.initialize() the instant the process was started, so the handshake ran
before the process could answer and failed; the outer lifecycle retry then
rebuilt the process, churning it in a loop.

Verified decisively: attaching + initialize() against a mid-cold-start process
times out on attempt 1 (process still installing) but SUCCEEDS at t+0.6s on
attempt 2 once the process is ready. So the fix is to retry the handshake in
place, not to rebuild the process.

Changes (mcp_stdio.initialize):
- Start the managed process ONCE, then loop attach WS -> ClientSession ->
  initialize() within the startup_timeout budget, tearing down each failed
  attempt cleanly, until the handshake succeeds or the budget elapses. A
  successful transport/session is transferred into the owner exit stack via a
  small _TransferredStack adapter.
- Bound each attempt with asyncio.wait_for(initialize, _HANDSHAKE_ATTEMPT_TIMEOUT_SEC=10s)
  so a cold-starting process fails fast and retries instead of hanging until
  the transport drops.
- Stop retrying ONLY when the process has DEFINITIVELY exited: new
  _managed_process_has_exited() (checks EXITED status) replaces the previous
  not-_managed_process_is_running() test, which false-negatived on a
  just-spawned process that had not yet reported RUNNING and made the loop bail
  to the outer rebuild path (relay then rejected the early re-attach with HTTP 400).

Adds a unit test that fails the first two handshakes with the process alive and
asserts the loop retries to success while starting the process exactly once.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 14:16:57 +08:00
Hyu 9a8cdde86c refactor(mcp): make MCP test reuse the shared Box session (near-instant tests) (#2304)
* refactor(mcp): make MCP test reuse the shared Box session instead of a per-test session

Testing an MCP server (config-page "test" button) previously spun up a fresh
isolated mcp-test-<uuid> Box session every time: cold-start the container, run
the dependency bootstrap, probe, then tear the whole session down. That is slow
(tens of seconds) and, on an already-hosted server, wasteful — the server is
already running in the shared session.

Change the test to reuse the shared session / live process:

- _build_box_session_id: transient tests now use mcp-shared, the same Box
  session as live servers, so a test reuses the running container (and, for an
  existing server, its live managed process) instead of a cold per-test session.
- cleanup_session: a transient test no longer deletes the whole session (which
  under the shared model would kill every other MCP server in the container). It
  stops only its own process_id, exactly like a live server. Isolation is now at
  the process level (distinct process_id per server/test), not the session level.
- test_mcp_server (persisted server): reuse the live connection with a real
  list_tools refresh/probe; only fall back to a full start() when there is no
  live connection to probe or the refresh fails, instead of an ERROR->start()
  rebuild.

Trade-off: a failing test now shares the container with live servers rather than
a throwaway session. Accepted deliberately in favour of near-instant tests;
process-level isolation keeps a test from stopping another server's process.

* chore(deps): pin langbot-plugin 0.4.9 for the nsjail RLIMIT_AS node/npx MCP fix

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 13:14:14 +08:00
Hyu a2655e16f8 fix(mcp): survive transient WS transport drops for Box stdio MCP servers (#2303)
* fix(mcp): survive transient WS transport drops for Box stdio MCP servers

A Box-backed stdio MCP server (e.g. pab1it0/prometheus) would periodically
error on the frontend with Box managed process exited unexpectedly /
Failed after 4 attempts once the session had been alive for a while.

Root cause: the managed MCP process lives in the Box runtime and SURVIVES a
WebSocket transport drop, but _lifecycle_loop treated any monitor completion
as a fatal process death. It then ran the finally-block cleanup — which STOPS
the still-healthy managed process — and did a full 4-attempt exponential
backoff rebuild. Under an occasionally-stalled single-worker event loop the
mcp websocket client misses a ping/pong, the transport drops, and this
self-inflicted teardown loop is what the user sees.

Fixes:
- _lifecycle_loop: when the health monitor completes, re-check the real
  managed-process state. If the process is still running, the transport
  merely dropped: raise an internal _TransportReconnect signal instead of
  Box managed process exited unexpectedly.
- _lifecycle_loop_with_retry: handle _TransportReconnect as a free, uncounted
  reconnect (does not consume the fatal retry budget), so a long-lived session
  survives arbitrarily many transient drops.
- finally-block: gate managed-process teardown on a _preserve_managed_process
  flag so a transport-only reconnect closes just the WS, not the process.
- BoxStdioSessionRuntime.initialize: reuse an already-running managed process
  instead of stopping+rebuilding it (which also re-ran the slow dependency
  bootstrap); only (re)start when none is running. Adds
  _managed_process_is_running() helper.

Pairs with langbot-plugin-sdk fix adding a server-driven WS heartbeat to the
managed-process relay, which prevents most drops in the first place.

* style(mcp): ruff format

* chore(deps): pin langbot-plugin 0.4.8 for the managed-process WS heartbeat fix

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-03 12:24:24 +08:00
Hyu f89611f39a chore: release v4.10.5 2026-07-03 00:59:53 +08:00
RockChinQ 04a199a3b2 fix: preserve MCP server ids with slashes
Fixes #2301
2026-07-03 00:17:29 +08:00
Hyu f85278f98b Fix monitoring CI regressions 2026-07-02 17:22:33 +08:00
Hyu 0ce382fc8b Improve pipeline monitoring and AI tab resilience 2026-07-02 16:53:50 +08:00
Hyu e32e515bc9 Tone down tool call bubbles 2026-07-02 16:53:50 +08:00
Hyu c809e3d14f Add tool call observability 2026-07-02 16:53:50 +08:00
Hyu b1bc05d5d3 Improve monitoring conversation turns 2026-07-02 16:53:50 +08:00
Stevenqin 13dba887d5 fix(wecomcs): implement proactive text sends (#2300) 2026-07-02 14:39:05 +08:00
彼方 cc7a13158e fix(aiocqhttp): resolve group member metadata (#2298)
* fix(aiocqhttp): resolve group member metadata

* style: ruff format
2026-07-01 20:00:47 +08:00
RockChinQ 7de7c7f714 docs: update Product Hunt badge to featured 2026-06-30 08:23:55 -04:00
Junyan Qin 85923e3d7b fix(migrations): shorten mcp resource revision id 2026-06-30 19:39:23 +08:00
Junyan Qin c7c6c5dc51 fix(i18n): add skill tools tooltip translations 2026-06-30 19:37:57 +08:00
Junyan Qin b056646696 fix(migrations): add mcp resource preferences 2026-06-30 19:36:04 +08:00
advancer-young 096ec1a8ce feat(mcp): support mcp resources (#2215)
* feat(mcp): support mcp resources

* feat(web): split MCP resources into tab

* docs: add MCP resources PR review

* feat(mcp): productionize resource support

* feat(mcp): scope local agent tools and resources

* fix(web): gate space embedding models behind login

* fix(web): prevent clipped space model CTA

* test: update preproc resource tool expectations

* fix(web): expose skill authoring tools in selector

---------

Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-06-30 19:16:30 +08:00
RockChinQ 2618e06492 docs: update 302.AI referral link to share.302ai.cn 2026-06-30 05:10:17 -04:00
彼方 5c8c0eb17b test: use package import paths (#2297) 2026-06-30 10:51:34 +08:00
彼方 ccc51522cf fix(aiocqhttp): normalize base64 media payloads (#2296)
* fix(aiocqhttp): normalize image and voice base64 payloads

* fix(aiocqhttp): support file messages from base64 payloads

* test(aiocqhttp): use package import path
2026-06-30 10:51:09 +08:00
彼方 6a99a83f2d chore(scripts): mark scripts executable (#2295) 2026-06-30 10:50:26 +08:00
Hyu ebab5343cf fix(i18n): add bots.admins keys to all locale files (#2292)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-27 17:38:30 +08:00
Hyu b97cc800d3 feat(platform): migrate bot admins from config.yaml to database (#2289)
* feat(platform): migrate bot admins from config.yaml to database

- Add BotAdmin ORM model (bot_admins table) scoped per bot_uuid
- Add Alembic migration 0007 to create table and migrate legacy config admins
- Remove top-level admins key from config.yaml template
- Add GET/POST/DELETE /api/v1/platform/bots/<uuid>/admins endpoints
- Update cmdmgr privilege check to query bot_admins table (bot-scoped)
- Add BotAdminsPanel frontend component in bot detail sessions tab
- Add i18n keys (zh-Hans, en-US)

* fix(ci): ruff/prettier format, fix test_importutil assertion

* fix(ci): prettier format BackendClient.ts and i18n locales

* fix(ci): eslint-prettier fix BackendClient.ts single-param formatting

* refactor: move admin management into session monitor, fix session_id enum format

- Remove standalone admins tab, replace with BotAdminsDialog in session monitor
- Add admin toggle button inline in chat header next to Active status
- Add BotAdminsDialog component with useBotAdmins hook
- Fix session_id written as LauncherTypes.PERSON_xxx instead of person_xxx
  (monitoring_helper.py x4, pipelinemgr.py x1 missing .value on launcher_type)
- Fix duplicate platform/person label in session list and chat header
- Migrate existing malformed session_id records in DB

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-27 17:18:19 +08:00
131 changed files with 11269 additions and 958 deletions
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Production-grade platform for building agentic IM bots.</h3>
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
@@ -51,7 +51,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -136,7 +136,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features)
+2 -2
View File
@@ -51,7 +51,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -136,7 +136,7 @@ docker compose --profile all up -d
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
@@ -50,7 +50,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -135,7 +135,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
@@ -50,7 +50,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -132,7 +132,7 @@ docker compose --profile all up -d
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
@@ -50,7 +50,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -135,7 +135,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
@@ -50,7 +50,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -135,7 +135,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
@@ -50,7 +50,7 @@ LangBot — это **платформа с открытым исходным к
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -131,7 +131,7 @@ docker compose --profile all up -d
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ |
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
+2 -2
View File
@@ -52,7 +52,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
---
@@ -137,7 +137,7 @@ docker compose --profile all up -d
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
### TTS(語音合成)
+3 -3
View File
@@ -5,7 +5,7 @@
<div align="center">
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production&#0045;grade&#0032;IM&#0032;bot&#0032;made&#0032;easy&#0046; | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&amp;theme=light&amp;t=1782822143403"></a>
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
@@ -50,7 +50,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
---
@@ -135,7 +135,7 @@ docker compose --profile all up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
+171
View File
@@ -0,0 +1,171 @@
# Valkey Search Vector Database Integration
This document describes how to use **Valkey Search** (the search/vector module bundled in
`valkey/valkey-bundle`) as the vector database backend for LangBot's knowledge base (RAG)
feature.
## What is Valkey Search?
**Valkey Search** is a module that adds vector similarity search and full-text search to
[Valkey](https://valkey.io/), the open-source, BSD-licensed in-memory data store forked from
Redis OSS. It is distributed in the `valkey/valkey-bundle` image alongside other modules
(JSON, Bloom, LDAP).
LangBot talks to Valkey through the official [`valkey-glide`](https://pypi.org/project/valkey-glide/)
client (Rust core + async Python wrapper), using its native `ft` (search) command namespace.
### Key Features
- **Vector search**: ANN via HNSW or exact via FLAT, with COSINE / L2 / IP distance metrics
- **Full-text search**: term, prefix and phrase matching over indexed text fields
- **Hybrid search**: a metadata/text filter pre-selects candidates, then KNN ranks them
- **In-memory speed**: vectors and documents are stored as Valkey HASH keys
- **Auth + TLS**: optional username/password and TLS for production (toB / SaaS) deployments
### Licensing
- Valkey core and the Search module are **BSD-3-Clause**.
- The `valkey-glide` client is **Apache-2.0**.
Both are compatible with LangBot.
## Installation
Valkey Search support is included automatically on Linux and macOS. The official `valkey-glide`
client does not currently publish a Windows package, so LangBot skips this optional dependency on
Windows; LangBot remains usable there, but the Valkey Search backend is unavailable. To install the
client manually on a supported platform:
```bash
pip install 'valkey-glide>=2.4.1,<3.0.0'
```
You also need a running Valkey server with the Search module loaded. The simplest way is the
bundled image:
```bash
# Run valkey-bundle (includes the Search module) on host port 6380
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
# (docker run ... works identically)
```
`valkey-bundle` ships multi-arch images (linux/amd64 + linux/arm64), so it runs on both CI
(x86_64) and Apple-silicon dev machines.
## Configuration
Valkey Search is **opt-in and disabled by default** — the default `vdb.use` stays `chroma`,
so existing single-process deployments are unaffected. To enable it, edit your `config.yaml`:
```yaml
vdb:
use: valkey_search
valkey_search:
host: 'localhost'
port: 6379 # use 6380 if you started the container as shown above
db: 0
password: '' # optional (ACL / requirepass) — never logged
username: '' # optional (ACL user)
tls: false # optional (toB / SaaS)
index_algorithm: 'HNSW' # HNSW | FLAT
distance_metric: 'COSINE' # COSINE | L2 | IP
request_timeout: 5000 # per-request timeout in ms
```
| Option | Default | Description |
|--------|---------|-------------|
| `host` | `localhost` | Valkey host |
| `port` | `6379` | Valkey port |
| `db` | `0` | Logical database id |
| `password` | `''` | Optional auth password (empty = no auth). Never logged. |
| `username` | `''` | Optional ACL username. Configuring a username without a password fails closed (raises) rather than connecting unauthenticated. |
| `tls` | `false` | Enable TLS for the connection |
| `index_algorithm` | `HNSW` | `HNSW` (approximate) or `FLAT` (exact) |
| `distance_metric` | `COSINE` | `COSINE`, `L2`, or `IP` |
| `request_timeout` | `5000` | Per-request timeout in milliseconds. The valkey-glide default (250ms) is too low for vector KNN under load; raise it further for remote/cross-AZ Valkey. |
### Connection behavior
The backend uses a **lazy** connection (`lazy_connect=True`): the client is created on first
use and the connection is deferred to the first command. A misconfigured or unreachable Valkey
server therefore does **not** block LangBot from booting — knowledge-base operations will error
at call time instead, and you can recover by switching `vdb.use` back to another backend.
The connection sets a fixed `client_name` of `langbot_vector_client` so it is identifiable in
`CLIENT LIST` and monitoring dashboards.
## Supported search types
| Type | Behavior |
|------|----------|
| `vector` | Pure KNN over the embedding field |
| `full_text` | Term/phrase match over the indexed `document` text field |
| `hybrid` | Metadata/text filter **pre-selects** candidates, then KNN ranks them |
### ⚠️ Important: `vector_weight` is NOT honored
Valkey Search hybrid queries follow a **filter-then-KNN** model: the filter (and/or full-text
clause) narrows the candidate set, and the KNN stage ranks the survivors by vector distance.
There is **no native weighted score fusion** (unlike, e.g., SeekDB's RRF boost).
For interface compatibility the backend still accepts a `vector_weight` argument, but it is
**ignored** — passing different weights does not change result ordering. The first time a
non-default weight is supplied, the backend logs a one-time warning.
If weighted hybrid ranking is needed in the future, it can be added **application-side** (run
vector KNN and full-text search separately and blend the scores). That is intentionally out of
scope for this integration.
## Metadata & filtering
Documents are stored as Valkey HASH keys under the prefix `kb:{collection}:{id}` with fields:
- `vector` — the embedding, packed as little-endian FLOAT32
- `document` — the raw text (indexed as TEXT for full-text/hybrid search)
- `file_id` — promoted to an indexed TAG field so it is filterable
- `metadata_json` — the full metadata dict, preserved verbatim as JSON
Only **indexed** fields are filterable. Currently that is `file_id`. Filters referencing
non-indexed metadata keys are dropped with a warning (the same pragmatism used by the Milvus
and pgvector backends). All other metadata still round-trips intact via `metadata_json`.
Supported filter operators (canonical Chroma-style `where` syntax): `$eq`, `$ne`, `$gt`,
`$gte`, `$lt`, `$lte`, `$in`, `$nin`. Multiple top-level keys are AND-ed.
## Testing
Unit tests (filter mapping, float32 packing, reply parsing, import guard) run in the fast lane
with no server:
```bash
uv run pytest tests/unit_tests/vector/test_valkey_search_filter.py -q
```
Integration tests are **slow-gated** on `TEST_VALKEY_URL` and require a running server:
```bash
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
TEST_VALKEY_URL=valkey://localhost:6380 \
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
```
The default upstream fast CI lane (`-m "not slow"`) skips these, matching the existing
PostgreSQL migration-test precedent.
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| Tests skip with "Valkey Search module not available" | The server is plain Valkey without the Search module. Use the `valkey/valkey-bundle` image. |
| `ConnectionError` at call time | Check `host`/`port`/auth; remember `lazy_connect` defers errors to first use. |
| Empty search results right after insert | The Search indexer is asynchronous; results become visible within a short delay. The integration tests poll/retry to account for this. |
| Hybrid ranking ignores `vector_weight` | Expected — see the caveat above. |
## Production considerations
- **Cluster mode**: Valkey Search in cluster mode uses an additional coordination port. This
integration targets standalone mode; cluster support is a future consideration.
- **Persistence**: configure Valkey RDB/AOF persistence if the knowledge base must survive
restarts; otherwise an in-memory store is ephemeral.
- **Security**: set `password`/`username` and `tls: true` for any non-local deployment.
Credentials are never written to logs.
+196
View File
@@ -0,0 +1,196 @@
# MCP Resources PR #2215 Review
> 更新日期: 2026-06-29
> 分支: `mcp_resources`
> PR: langbot-app/LangBot#2215
> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向
## 结论
PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。
但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools
- `langbot_mcp_list_resources`
- `langbot_mcp_read_resource`
这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。
建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。
## 当前实现判断
当前 AgentRunner 集成路径如下:
```text
Pipeline 绑定 MCP server
-> query.variables['_pipeline_bound_mcp_servers']
-> Preproc 为 local-agent 加载工具
-> ToolManager.get_all_tools()
-> MCPLoader 注入 synthetic resource tools
-> LocalAgentRunner 将工具 schema 传给模型
-> 模型发起 list/read tool call
-> ToolManager.execute_func_call()
-> MCPLoader 调 MCP session.list_resources/read_resource
-> tool result 回灌给模型
```
这个路径的优点是:
- 复用现有工具调用机制,改动范围小。
- Agent 可以按需探索资源,不需要每轮预先读取所有资源。
- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。
- 对已有 MCP tools 行为影响较小。
主要问题是:
- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。
- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。
- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。
- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。
- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。
- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。
## 主流项目做法
### MCP 官方规范
MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。
这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。
参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
### VS Code Copilot
VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。
这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。
参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers
### Anthropic SDK
Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。
这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。
参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
### LangChain MCP Adapters
LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。
这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。
参考: https://docs.langchain.com/oss/python/langchain/mcp
### OpenAI Agents SDK
OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources``list_resource_templates``read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。
参考: https://openai.github.io/openai-agents-python/mcp/
### Cline
Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。
这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。
参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts
## 建议架构方向
### 1. 保留探索型工具
保留当前两个 synthetic tools
- `langbot_mcp_list_resources`
- `langbot_mcp_read_resource`
它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。
### 2. 增加一等 Resource Context
新增一个 Host 层资源上下文概念,例如:
```text
PipelineResourceBinding
ConversationResourceAttachment
MessageResourceAttachment
```
Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。
### 3. 打通 UI 与 Agent 上下文
当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作:
- 添加到本轮上下文
- 固定到当前 pipeline
- 固定到当前 bot / conversation
- 查看资源读取历史和错误
这样 UI 资源管理能力才能真正影响 Agent 行为。
### 4. 支持 resource templates
MCP resource templates 允许 server 暴露参数化资源,例如:
```text
repo://{owner}/{repo}/file/{path}
log://{service}/{date}
```
LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。
### 5. 增加资源处理策略
建议补齐:
- 文本资源 token budget 与截断策略。
- 大文件 chunk 与摘要策略。
- 图片/blob 的模型能力判断与 fallback。
- MIME 类型白名单与安全限制。
- 缓存与过期策略。
- `resources/listChanged` 或订阅更新。
- resource read trace,便于审计 Agent 读取了什么上下文。
## 推荐落地顺序
### Phase 1: 完成当前 PR 可用性
- 保留 synthetic tools。
- 明确文档说明当前 Agent 集成是 tool-mediated。
- 完善资源工具描述,降低模型误用概率。
- 给 read/list 增加大小限制和更清晰的 MIME 处理。
- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。
### Phase 2: 做 Host-owned context attachments
- 在 pipeline 或 conversation 层新增 resource attachment 配置。
- Preproc 读取已绑定 resources,注入模型上下文。
- UI 支持“添加到上下文 / 固定到 pipeline”。
- 记录每轮实际注入的 resource URI 和 token 消耗。
### Phase 3: 做完整 MCP Resources 能力
- 支持 resource templates。
- 支持资源订阅更新。
- 支持 chunk、summary、RAG 化接入。
- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。
## 最终建议
PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。
但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列:
```text
Tools -> Agent 可以执行的动作
Resources -> Host/用户/Agent 可以选择的上下文数据
Prompts -> 可复用的任务模板
Knowledge -> 可检索、可索引的长期知识
```
这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.4"
version = "4.10.5"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.4.6",
"langbot-plugin==0.4.13",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -80,6 +80,7 @@ dependencies = [
"pgvector>=0.4.1",
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
]
keywords = [
"bot",
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -138,6 +138,39 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_tool_calls() -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
session_ids = quart.request.args.getlist('sessionId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0))
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=offset,
)
return self.success(
data={
'tool_calls': tool_calls,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_embedding_calls() -> str:
"""Get embedding call records"""
@@ -284,6 +317,16 @@ class MonitoringRouterGroup(group.RouterGroup):
offset=0,
)
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
end_time=end_time,
limit=limit,
offset=0,
)
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
bot_ids=bot_ids if bot_ids else None,
@@ -318,12 +361,14 @@ class MonitoringRouterGroup(group.RouterGroup):
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
'toolCalls': tool_calls,
'embeddingCalls': embedding_calls,
'sessions': sessions,
'errors': errors,
'totalCount': {
'messages': messages_total,
'llmCalls': llm_calls_total,
'toolCalls': tool_calls_total,
'embeddingCalls': embedding_calls_total,
'sessions': sessions_total,
'errors': errors_total,
@@ -86,6 +86,10 @@ class PipelinesRouterGroup(group.RouterGroup):
'available_plugins': plugins,
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
'available_mcp_servers': mcp_servers,
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
'mcp_resource_agent_read_enabled': extensions_prefs.get(
'mcp_resource_agent_read_enabled', True
),
'bound_skills': extensions_prefs.get('skills', []),
'available_skills': available_skills,
}
@@ -99,6 +103,8 @@ class PipelinesRouterGroup(group.RouterGroup):
bound_plugins = json_data.get('bound_plugins', [])
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
bound_skills = json_data.get('bound_skills', [])
bound_mcp_resources = json_data.get('bound_mcp_resources')
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
await self.ap.pipeline_service.update_pipeline_extensions(
pipeline_uuid,
@@ -108,6 +114,8 @@ class PipelinesRouterGroup(group.RouterGroup):
enable_all_mcp_servers,
bound_skills=bound_skills,
enable_all_skills=enable_all_skills,
bound_mcp_resources=bound_mcp_resources,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
)
return self.success()
@@ -2,6 +2,7 @@ from __future__ import annotations
import quart
import traceback
from urllib.parse import unquote
from ... import group
@@ -28,11 +29,11 @@ class MCPRouterGroup(group.RouterGroup):
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
@@ -57,12 +58,72 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id})
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get resources from an MCP server"""
server_name = unquote(server_name)
try:
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
return self.success(
data={
'resources': resources,
'resource_templates': templates,
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
}
)
except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
@self.route(
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""Get resource templates from an MCP server"""
server_name = unquote(server_name)
try:
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
return self.success(data={'resource_templates': templates})
except Exception as e:
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get logs from an MCP server"""
server_name = unquote(server_name)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
limit = 200
limit = min(limit, 500)
level = quart.request.args.get('level') or None
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
return self.success(data={'logs': logs})
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Read a resource from an MCP server"""
server_name = unquote(server_name)
data = await quart.request.json
uri = data.get('uri')
if not uri:
return self.http_status(400, -1, 'URI is required')
try:
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
server_name,
uri,
max_bytes=data.get('max_bytes'),
include_blob=bool(data.get('include_blob', False)),
)
return self.success(data=envelope)
except Exception as e:
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
@@ -1,5 +1,7 @@
from __future__ import annotations
import quart
from ... import group
@@ -9,25 +11,41 @@ class ToolsRouterGroup(group.RouterGroup):
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
"""获取所有可用工具列表"""
tools = await self.ap.tool_mgr.get_all_tools()
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
tool_list = []
for tool in tools:
tool_list.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
}
)
if pipeline_uuid:
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
return self.success(data={'tools': tool_list})
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
if not extensions_prefs.get('enable_all_plugins', True):
bound_plugins = [
f'{plugin.get("author", "")}/{plugin.get("name", "")}'
for plugin in extensions_prefs.get('plugins', [])
if isinstance(plugin, dict) and plugin.get('name')
]
if not extensions_prefs.get('enable_all_mcp_servers', True):
bound_mcp_servers = [
server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str)
]
return self.success(
data={
'tools': await self.ap.tool_mgr.get_tool_catalog(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=True,
)
}
)
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(tool_name: str) -> str:
"""获取特定工具详情"""
tools = await self.ap.tool_mgr.get_all_tools()
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
for tool in tools:
if tool.name == tool_name:
@@ -243,6 +243,7 @@ class MaintenanceService:
tables = {
'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id,
+67 -2
View File
@@ -48,6 +48,17 @@ class MCPService:
if total_extensions >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
server_name = str(server_data.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
server_data['name'] = server_name
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
@@ -136,6 +147,32 @@ class MCPService:
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
"""Get resource templates from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
async def read_mcp_server_resource_envelope(
self,
server_name: str,
uri: str,
*,
max_bytes: int | None = None,
include_blob: bool = False,
) -> dict:
"""Read a resource from a specific MCP server with metadata."""
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
if max_bytes is not None:
kwargs['max_bytes'] = max_bytes
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
@@ -151,10 +188,22 @@ class MCPService:
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
if persisted_session.status == MCPSessionStatus.ERROR:
# Testing a persisted server should REUSE its live shared-session
# process, not rebuild it. Try a lightweight refresh (a real
# list_tools probe over the existing connection) first; only fall
# back to a full start() when the session has no live connection
# to probe (never connected, or the process is actually gone).
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
else:
await persisted_session.refresh()
try:
await persisted_session.refresh()
except Exception:
# The live connection was stale/dropped: reconnect once
# (reusing the live managed process where possible) and
# re-probe, instead of reporting a false failure.
await persisted_session.start()
# Surface the discovered tools so the config page can render them
# even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
@@ -195,3 +244,19 @@ class MCPService:
context=ctx,
)
return wrapper.id
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
"""Get recent log lines captured from the MCP server's stderr."""
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
if not session:
return []
# Get logs from the session's buffer
logs = list(session._log_buffer)
# Filter by level if specified
if level:
logs = [log for log in logs if log.get('level') == level]
# Return the most recent 'limit' logs
return logs[-limit:]
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
import datetime
import json
import sqlalchemy
from ....core import app
@@ -50,6 +51,12 @@ class MonitoringService:
persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id,
),
(
'monitoring_tool_calls',
persistence_monitoring.MonitoringToolCall,
persistence_monitoring.MonitoringToolCall.timestamp,
persistence_monitoring.MonitoringToolCall.id,
),
(
'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall,
@@ -131,6 +138,68 @@ class MonitoringService:
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None:
"""Serialize tool arguments/results for monitoring storage."""
if payload is None:
return None
if isinstance(payload, str):
text = payload
else:
try:
text = json.dumps(payload, ensure_ascii=False, default=str)
except Exception:
text = str(payload)
if len(text) <= max_length:
return text
return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]'
async def _get_message_for_tool_context(
self,
message_id: str | None = None,
session_id: str | None = None,
):
if message_id:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
)
)
row = result.first()
if row:
return row[0]
if not session_id:
return None
user_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(
sqlalchemy.and_(
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.role == 'user',
)
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(user_query)
row = result.first()
if row:
return row[0]
any_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(any_query)
row = result.first()
return row[0] if row else None
# ========== Recording Methods ==========
async def record_message(
@@ -220,6 +289,57 @@ class MonitoringService:
return call_id
async def record_tool_call(
self,
tool_name: str,
tool_source: str,
duration: int,
status: str = 'success',
bot_id: str | None = None,
bot_name: str | None = None,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
arguments: object | None = None,
result: object | None = None,
error_message: str | None = None,
) -> str:
"""Record a tool call."""
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
if context_message:
bot_id = bot_id or context_message.bot_id
bot_name = bot_name or context_message.bot_name
pipeline_id = pipeline_id or context_message.pipeline_id
pipeline_name = pipeline_name or context_message.pipeline_name
session_id = session_id or context_message.session_id
message_id = message_id or context_message.id
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'tool_name': tool_name,
'tool_source': tool_source,
'duration': max(0, duration),
'status': status,
'bot_id': bot_id or 'unknown',
'bot_name': bot_name or 'Unknown',
'pipeline_id': pipeline_id or 'unknown',
'pipeline_name': pipeline_name or 'Unknown',
'session_id': session_id,
'message_id': message_id,
'arguments': self._serialize_tool_payload(arguments),
'result': self._serialize_tool_payload(result),
'error_message': self._serialize_tool_payload(error_message),
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data)
)
return call_id
async def record_embedding_call(
self,
model_name: str,
@@ -749,6 +869,58 @@ class MonitoringService:
total,
)
async def get_tool_calls(
self,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids))
if session_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id))
if conditions:
count_query = count_query.where(sqlalchemy.and_(*conditions))
count_result = await self.ap.persistence_mgr.execute_async(count_query)
total = count_result.scalar() or 0
query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by(
persistence_monitoring.MonitoringToolCall.timestamp.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
tool_calls_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_calls_rows
],
total,
)
async def get_embedding_calls(
self,
start_time: datetime.datetime | None = None,
@@ -971,6 +1143,34 @@ class MonitoringService:
else:
error_llm_calls += 1
# Get tool calls for this session
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
)
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
tool_rows = tool_result.all()
tool_calls = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
)
for row in tool_rows
]
total_tool_calls = len(tool_rows)
success_tool_calls = 0
error_tool_calls = 0
total_tool_duration = 0
for row in tool_rows:
tool_call = row[0] if isinstance(row, tuple) else row
total_tool_duration += tool_call.duration
if tool_call.status == 'success':
success_tool_calls += 1
else:
error_tool_calls += 1
# Get errors for this session
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
@@ -1014,6 +1214,14 @@ class MonitoringService:
'total_tokens': total_tokens,
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
},
'tool_calls': tool_calls,
'tool_stats': {
'total_calls': total_tool_calls,
'success_calls': success_tool_calls,
'error_calls': error_tool_calls,
'total_duration_ms': total_tool_duration,
'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0,
},
'errors': errors,
'session_duration_seconds': session_duration_seconds,
}
@@ -100,6 +100,8 @@ class PipelineService:
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
await self.ap.persistence_mgr.execute_async(
@@ -193,6 +195,8 @@ class PipelineService:
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
),
}
@@ -217,6 +221,8 @@ class PipelineService:
enable_all_mcp_servers: bool = True,
bound_skills: list[str] = None,
enable_all_skills: bool = True,
bound_mcp_resources: list[dict] = None,
mcp_resource_agent_read_enabled: bool | None = None,
) -> None:
"""Update the bound plugins and MCP servers for a pipeline"""
# Get current pipeline
@@ -236,10 +242,14 @@ class PipelineService:
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
extensions_preferences['enable_all_skills'] = enable_all_skills
extensions_preferences['plugins'] = bound_plugins
if mcp_resource_agent_read_enabled is not None:
extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
if bound_mcp_servers is not None:
extensions_preferences['mcp_servers'] = bound_mcp_servers
if bound_skills is not None:
extensions_preferences['skills'] = bound_skills
if bound_mcp_resources is not None:
extensions_preferences['mcp_resources'] = bound_mcp_resources
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
@@ -49,6 +49,28 @@ class MonitoringLLMCall(Base):
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
class MonitoringToolCall(Base):
"""Tool call records"""
__tablename__ = 'monitoring_tool_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
class MonitoringSession(Base):
"""Session tracking records"""
@@ -26,7 +26,14 @@ class LegacyPipeline(Base):
extensions_preferences = sqlalchemy.Column(
sqlalchemy.JSON,
nullable=False,
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
default={
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
},
)
@@ -0,0 +1,95 @@
"""add mcp resource preferences to pipelines
Revision ID: 0008_mcp_resource_prefs
Revises: 0007_add_bot_admins
Create Date: 2026-06-30
"""
from __future__ import annotations
import json
from typing import Any
import sqlalchemy as sa
from alembic import op
revision = '0008_mcp_resource_prefs'
down_revision = '0007_add_bot_admins'
branch_labels = None
depends_on = None
_PIPELINE_TABLE = sa.table(
'legacy_pipelines',
sa.column('uuid', sa.String(255)),
sa.column('extensions_preferences', sa.JSON()),
)
def _has_extensions_preferences_table(conn: sa.Connection) -> bool:
inspector = sa.inspect(conn)
if 'legacy_pipelines' not in inspector.get_table_names():
return False
columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')}
return 'extensions_preferences' in columns
def _decode_preferences(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
try:
decoded = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(decoded, dict):
return decoded
return {}
def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None:
conn.execute(
_PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences)
)
def upgrade() -> None:
conn = op.get_bind()
if not _has_extensions_preferences_table(conn):
return
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
for uuid, raw_preferences in rows:
preferences = _decode_preferences(raw_preferences)
changed = False
if 'mcp_resources' not in preferences:
preferences['mcp_resources'] = []
changed = True
if 'mcp_resource_agent_read_enabled' not in preferences:
preferences['mcp_resource_agent_read_enabled'] = True
changed = True
if changed:
_update_preferences(conn, uuid, preferences)
def downgrade() -> None:
conn = op.get_bind()
if not _has_extensions_preferences_table(conn):
return
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
for uuid, raw_preferences in rows:
preferences = _decode_preferences(raw_preferences)
changed = False
for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'):
if key in preferences:
preferences.pop(key)
changed = True
if changed:
_update_preferences(conn, uuid, preferences)
@@ -0,0 +1,17 @@
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
from .. import migration
@migration.migration_class(26)
class DBMigrateMonitoringToolCalls(migration.DBMigration):
"""Add monitoring_tool_calls table"""
async def upgrade(self):
"""Upgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True)
async def downgrade(self):
"""Downgrade"""
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True)
+11
View File
@@ -96,6 +96,15 @@ class RuntimePipeline:
extensions_prefs = pipeline_entity.extensions_preferences or {}
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
self.mcp_resource_attachments = local_agent_config.get(
'mcp-resources',
extensions_prefs.get('mcp_resources', []),
)
self.mcp_resource_agent_read_enabled = local_agent_config.get(
'mcp-resource-agent-read-enabled',
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
)
if self.enable_all_plugins:
# None indicates to use all available plugins
@@ -116,6 +125,8 @@ class RuntimePipeline:
# Store bound plugins and MCP servers in query for filtering
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
# Record query start for monitoring
try:
+25 -3
View File
@@ -25,6 +25,21 @@ class PreProcessor(stage.PipelineStage):
- use_funcs
"""
@staticmethod
def _filter_selected_tools(
tools: list,
local_agent_config: dict,
) -> list:
if local_agent_config.get('enable-all-tools', True) is not False:
return tools
selected_tools = local_agent_config.get('tools', [])
if not isinstance(selected_tools, list):
return []
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
return [tool for tool in tools if tool.name in selected_tool_names]
async def process(
self,
query: pipeline_query.Query,
@@ -32,6 +47,7 @@ class PreProcessor(stage.PipelineStage):
) -> entities.StageProcessResult:
"""Process"""
selected_runner = query.pipeline_config['ai']['runner']['runner']
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
include_skill_authoring = (
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
)
@@ -43,7 +59,7 @@ class PreProcessor(stage.PipelineStage):
if selected_runner == 'local-agent':
# Read model config — new format is { primary: str, fallbacks: [str] },
# but handle legacy plain string for backward compatibility
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
model_config = local_agent_config.get('model', {})
if isinstance(model_config, str):
# Legacy format: plain UUID string
primary_uuid = model_config
@@ -113,11 +129,14 @@ class PreProcessor(stage.PipelineStage):
# Get bound plugins and MCP servers for filtering tools
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
include_mcp_resource_tools=include_mcp_resource_tools,
)
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
@@ -128,11 +147,14 @@ class PreProcessor(stage.PipelineStage):
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
include_mcp_resource_tools=include_mcp_resource_tools,
)
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
sender_name = ''
+125 -12
View File
@@ -4,6 +4,7 @@ import asyncio
import traceback
import datetime
import json
import time
import aiocqhttp
import pydantic
@@ -16,6 +17,37 @@ from ...utils import image
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
def _normalize_base64_payload(value: str) -> str:
if value.startswith('base64://'):
return value.removeprefix('base64://')
if value.startswith('data:') and ';base64,' in value:
return value.split(';base64,', 1)[1]
return value
def _get_field(data: dict, key: str, default: str = '') -> str:
value = data.get(key)
if value is None:
return default
return str(value)
def _get_group_member_name(sender: dict) -> str:
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
return f'Group {group_id}'
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(
@@ -35,7 +67,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
elif type(msg) is platform_message.Image:
arg = ''
if msg.base64:
arg = msg.base64
arg = _normalize_base64_payload(msg.base64)
msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))
elif msg.url:
arg = msg.url
@@ -50,7 +82,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
elif type(msg) is platform_message.Voice:
arg = ''
if msg.base64:
arg = msg.base64
arg = _normalize_base64_payload(msg.base64)
msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))
elif msg.url:
arg = msg.url
@@ -62,7 +94,10 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
for node in msg.node_list:
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
elif isinstance(msg, platform_message.File):
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
file = msg.url or msg.path
if not file and msg.base64:
file = f'base64://{_normalize_base64_payload(msg.base64)}'
msg_list.append({'type': 'file', 'data': {'file': file, 'name': msg.name}})
elif isinstance(msg, platform_message.Face):
if msg.face_type == 'face':
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
@@ -324,16 +359,96 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self):
self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
self._group_name_negative_cache: dict[typing.Union[int, str], float] = {}
self._group_member_info_cache: dict[
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
] = {}
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
return event.source_platform_object
@staticmethod
async def target2yiri(event: aiocqhttp.Event, bot=None):
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
now = time.monotonic()
if group_id in self._group_name_cache:
group_name, expires_at = self._group_name_cache[group_id]
if expires_at > now:
return group_name
del self._group_name_cache[group_id]
if group_id in self._group_name_negative_cache:
expires_at = self._group_name_negative_cache[group_id]
if expires_at > now:
return ''
del self._group_name_negative_cache[group_id]
if bot is None:
return ''
try:
group_info = await asyncio.wait_for(
bot.get_group_info(group_id=group_id),
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return ''
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
if group_name:
self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
self._group_name_negative_cache.pop(group_id, None)
else:
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
return group_name
async def _get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
bot=None,
) -> dict:
now = time.monotonic()
cache_key = (group_id, user_id)
if cache_key in self._group_member_info_cache:
member_info, expires_at = self._group_member_info_cache[cache_key]
if expires_at > now:
return member_info
del self._group_member_info_cache[cache_key]
if cache_key in self._group_member_info_negative_cache:
expires_at = self._group_member_info_negative_cache[cache_key]
if expires_at > now:
return {}
del self._group_member_info_negative_cache[cache_key]
if bot is None:
return {}
try:
member_info = await asyncio.wait_for(
bot.get_group_member_info(group_id=group_id, user_id=user_id),
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
)
except Exception:
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
if isinstance(member_info, dict) and member_info:
self._group_member_info_cache[cache_key] = (
member_info,
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
)
self._group_member_info_negative_cache.pop(cache_key, None)
return member_info
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
return {}
async def target2yiri(self, event: aiocqhttp.Event, bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
if event.message_type == 'group':
permission = 'MEMBER'
group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id)
special_title = _get_field(event.sender, 'title')
if not special_title:
member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot)
special_title = _get_field(member_info, 'title')
if 'role' in event.sender:
if event.sender['role'] == 'admin':
@@ -343,14 +458,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
converted_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.sender['user_id'], # message_seq 放哪?
member_name=event.sender['nickname'],
member_name=_get_group_member_name(event.sender),
permission=permission,
group=platform_entities.Group(
id=event.group_id,
name=event.sender['nickname'],
name=group_name,
permission=platform_entities.Permission.Member,
),
special_title=event.sender['title'] if 'title' in event.sender else '',
special_title=special_title,
),
message_chain=yiri_chain,
time=event.time,
@@ -374,7 +489,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
@@ -433,9 +548,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
elif isinstance(component, platform_message.Image):
img_data = {}
if component.base64:
b64 = component.base64
if b64.startswith('data:'):
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
b64 = _normalize_base64_payload(component.base64)
img_data['file'] = f'base64://{b64}'
elif component.url:
img_data['file'] = component.url
+23 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing
import asyncio
import traceback
import uuid
import datetime
import pydantic
@@ -182,7 +183,28 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
if target_type != 'person':
raise ValueError('WeCom customer service only supports sending messages to person targets')
open_kfid = self.bot_account_id
external_userid = target_id
if '|' in target_id:
open_kfid, external_userid = target_id.split('|', 1)
if external_userid.startswith('u'):
external_userid = external_userid[1:]
if not open_kfid:
raise ValueError('WeCom customer service open_kfid is required before sending messages')
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text':
await self.bot.send_text_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
content=content['content'],
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
@@ -417,6 +417,30 @@ class LocalAgentRunner(runner.RequestRunner):
ce.text = final_user_message_text
break
mcp_loader = getattr(getattr(self.ap, 'tool_mgr', None), 'mcp_tool_loader', None)
if mcp_loader is not None:
resource_context = await mcp_loader.build_resource_context_for_query(query)
if resource_context:
resource_addition = (
'\n\nMCP resource context selected by LangBot host:\n'
f'{resource_context}\n\n'
'Use this context as read-only reference material. If it conflicts with the user message, '
'ask for clarification before taking external actions.'
)
if isinstance(user_message.content, str):
user_message.content += resource_addition
elif isinstance(user_message.content, list):
appended = False
for ce in user_message.content:
if ce.type == 'text':
ce.text = (ce.text or '') + resource_addition
appended = True
break
if not appended:
user_message.content.append(
provider_message.ContentElement.from_text(resource_addition.strip())
)
req_messages = self._build_request_messages(query, user_message)
try:
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import os
import shutil
import shlex
import threading
from contextlib import suppress
from contextlib import suppress, AsyncExitStack
from typing import TYPE_CHECKING, Any
import pydantic
@@ -57,6 +57,23 @@ class MCPSessionErrorPhase(enum.Enum):
BOX_UNAVAILABLE = 'box_unavailable'
def _get_default_memory_mb(ap) -> int:
"""Read box.default_memory_mb from instance config (env: BOX__DEFAULT_MEMORY_MB).
Falls back to 1536 MB a safe floor for Node.js V8 + WASM under nsjail.
Operators running memory-constrained hosts can lower this; those with large
machines can raise it. Individual MCP servers can still override via their
own box.memory_mb setting.
"""
try:
data = getattr(getattr(ap, 'instance_config', None), 'data', None)
if isinstance(data, dict):
return int(data.get('box', {}).get('default_memory_mb', 1536))
except (TypeError, ValueError):
pass
return 1536
class MCPServerBoxConfig(pydantic.BaseModel):
"""Structured configuration for running an MCP server inside a Box container."""
@@ -74,6 +91,35 @@ class MCPServerBoxConfig(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra='ignore')
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
class _TransferredStack:
"""Adapts an already-populated AsyncExitStack into an async context manager
so ownership of its resources can be transferred into another exit stack.
Entering is a no-op; exiting closes the wrapped stack (and thus the live WS
transport + ClientSession) when the owning session shuts down."""
def __init__(self, stack: AsyncExitStack):
self._stack = stack
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self._stack.aclose()
return False
class _ColdStartRetry(Exception):
"""Signal: the managed process is alive but not yet answering the MCP
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
installing). The outer lifecycle retry treats this like a transient
reconnect: it reuses the live process and does not count toward the fatal
retry budget, so a slow cold start is waited out rather than failing.
"""
class BoxStdioSessionRuntime:
"""Encapsulate Box-backed stdio MCP session orchestration."""
@@ -113,7 +159,17 @@ class BoxStdioSessionRuntime:
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
image=self.config.image,
cpus=self.config.cpus,
memory_mb=self.config.memory_mb,
# Node.js runtimes (npx/bunx) reserve large virtual address space and
# load WebAssembly modules (llhttp) on startup; the default 512 MB
# cgroup_mem_max is too small and causes OOM kills (return_code=137).
# Auto-bump to 1024 MB when the runner is npx/bunx/pnpm dlx.
# Per-server override wins; global default comes from
# config.yaml box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB).
# Hard floor of 1536 MB: enough for Node.js V8 + WASM without OOM.
# Per-server override wins; global default from config.yaml
# box.default_memory_mb (env: BOX__DEFAULT_MEMORY_MB), hard floor
# of 1536 MB so Node.js V8 + WASM never OOM under nsjail.
memory_mb=(self.config.memory_mb or _get_default_memory_mb(self.ap)),
pids_limit=self.config.pids_limit,
persistent=True,
)
@@ -173,28 +229,55 @@ class BoxStdioSessionRuntime:
stderr_preview = (result.stderr or '')[:500]
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
try:
process_workspace = (
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
if host_path
else workspace
# Reuse an already-running managed process instead of rebuilding it.
# The Box runtime keeps the managed process alive across a transient
# WebSocket transport drop, so on a reconnect we only need to re-attach
# the WS below. Rebuilding here would needlessly stop a healthy process
# and re-run the (slow, network-touching) dependency bootstrap.
if not await self._managed_process_is_running():
try:
process_workspace = (
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
if host_path
else workspace
)
payload = process_workspace.build_process_payload(
self.server_config['command'],
self.server_config.get('args', []),
env=self.server_config.get('env', {}),
cwd=process_cwd,
)
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise
else:
self.ap.logger.info(
f'MCP server {self.server_name}: reusing live managed process '
f'process_id={self.process_id} (transport reconnect)'
)
payload = process_workspace.build_process_payload(
self.server_config['command'],
self.server_config.get('args', []),
env=self.server_config.get('env', {}),
cwd=process_cwd,
)
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
await workspace.box_service.start_managed_process(workspace.session_id, payload)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# in the same task as the serve loop that follows. websocket_client and
# ClientSession use anyio task groups whose cancel scope is bound to the
# frame/stack that entered them, so they must live on the owner exit
# stack (not a deferred/transferred one) or the streams close the moment
# initialize() returns and the next request fails with "Connection
# closed".
#
# A slow (`npx -y <pkg>`) cold start makes this single attempt fail
# while the process is still alive — the package is still installing and
# cannot answer the handshake. We surface that to the outer retry loop
# as a _ColdStartRetry: it must NOT stop the process (it is healthy and
# will be reused) and must NOT consume the fatal retry budget. The next
# attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try:
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(
@@ -202,12 +285,19 @@ class BoxStdioSessionRuntime:
)
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
if not await self._managed_process_has_exited():
# Process is alive but not yet serving (cold start) — reconnect.
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
raise
try:
await self.owner.session.initialize()
except Exception:
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
except Exception as exc:
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
if not await self._managed_process_has_exited():
raise _ColdStartRetry(
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
)
raise
async def monitor_process_health(self) -> None:
@@ -234,8 +324,74 @@ class BoxStdioSessionRuntime:
)
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
return
# Capture stderr logs from the managed process
if isinstance(info, dict):
stderr_text = info.get('stderr', '') or info.get('stderr_preview', '')
else:
stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '')
if stderr_text and stderr_text != self.owner._last_stderr_text:
# Find new lines not in the previous snapshot
old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set()
new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines]
self.owner._last_stderr_text = stderr_text
import time as _time
for line in new_lines:
level = (
'error'
if any(k in line.upper() for k in ('ERROR', 'CRITICAL'))
else 'warning'
if 'WARNING' in line.upper()
else 'debug'
if 'DEBUG' in line.upper()
else 'info'
)
self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line})
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
async def _managed_process_is_running(self) -> bool:
"""Return True if this server's managed process exists and is running.
Used to decide whether initialize() must (re)start the process or can
simply re-attach the WebSocket transport to a process the Box runtime
kept alive across a transient transport drop.
"""
from langbot_plugin.box.models import BoxManagedProcessStatus
workspace = self._build_workspace()
try:
info = await workspace.get_managed_process(self.process_id)
except Exception:
return False
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
async def _managed_process_has_exited(self) -> bool:
"""Return True only if the process is DEFINITIVELY gone (reports EXITED).
Distinct from ``not _managed_process_is_running()``: a process that has
just been spawned may not yet report RUNNING, and a transient query
error is not proof of exit. During the cold-start handshake retry we
must NOT treat 'not yet running' or 'query failed' as a terminal
failure, or we bail out to the outer rebuild path and churn the
process (relay then rejects the early re-attach with HTTP 400). Only a
successful query that reports EXITED stops the retry loop.
"""
from langbot_plugin.box.models import BoxManagedProcessStatus
workspace = self._build_workspace()
try:
info = await workspace.get_managed_process(self.process_id)
except Exception:
# Unknown — treat as 'still coming up', not exited.
return False
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED)
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
source_path = normalize_host_path(host_path)
if not source_path:
@@ -342,16 +498,20 @@ class BoxStdioSessionRuntime:
workspace = self._build_workspace(host_path=None)
# Transient test sessions own their isolated Box session, so tear the
# whole session down rather than leaking it. This cannot affect live
# servers because they live in the separate shared session.
# Transient config-page tests now share the same 'mcp-shared' Box
# session as live servers, so we must NOT tear the session down here —
# that would kill every other MCP server in the container. A test is
# isolated at the process level: it ran under its own process_id, so we
# stop only that process, exactly like a live server does below. The
# shared session and all other servers' live processes are untouched.
# (Staged per-test workspace files are still cleaned up.)
if getattr(self.owner, 'is_transient', False):
try:
await workspace.cleanup()
await workspace.stop_managed_process(self.process_id)
except Exception as exc:
self.ap.logger.warning(
f'MCP server {self.server_name}: failed to delete transient test session '
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}'
f'MCP server {self.server_name}: failed to stop transient test process '
f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
)
await self._cleanup_staged_workspace()
return
@@ -33,6 +33,24 @@ class PluginToolLoader(loader.ToolLoader):
return all_functions
async def get_tool_catalog(self, bound_plugins: list[str] | None = None) -> list[dict[str, typing.Any]]:
catalog: list[dict[str, typing.Any]] = []
for tool in await self.ap.plugin_connector.list_tools(bound_plugins):
catalog.append(
{
'name': tool.metadata.name,
'description': tool.spec['llm_prompt'],
'human_desc': tool.metadata.description.en_US,
'parameters': tool.spec['parameters'],
'source': 'plugin',
'source_name': tool.owner,
'source_id': tool.owner,
}
)
return catalog
async def has_tool(self, name: str) -> bool:
"""检查工具是否存在"""
for tool in await self.ap.plugin_connector.list_tools():
+157 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import typing
import time
from typing import TYPE_CHECKING
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -59,6 +60,7 @@ class ToolManager:
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
all_functions: list[resource_tool.LLMTool] = []
@@ -66,10 +68,51 @@ class ToolManager:
if include_skill_authoring:
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
)
)
return all_functions
async def get_tool_catalog(
self,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
catalog: list[dict[str, typing.Any]] = []
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
for tool in tools:
catalog.append(
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
'source': source,
'source_name': source_name,
}
)
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
if include_skill_authoring:
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader:
for item in await self.mcp_tool_loader.get_tool_catalog(
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
):
catalog.append(item)
return catalog
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
"""Get tool by name from any active loader."""
for active_loader in (
@@ -100,21 +143,130 @@ class ToolManager:
return tools
def _get_query_session_id(self, query: pipeline_query.Query) -> str | None:
launcher_type = getattr(query, 'launcher_type', None)
launcher_id = getattr(query, 'launcher_id', None)
if launcher_type is None or launcher_id is None:
return None
launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type
return f'{launcher_type_value}_{launcher_id}'
async def _record_tool_call(
self,
*,
name: str,
source: str,
parameters: dict,
query: pipeline_query.Query,
duration_ms: int,
status: str,
result: typing.Any = None,
error_message: str | None = None,
) -> None:
monitoring_service = getattr(self.ap, 'monitoring_service', None)
if not monitoring_service:
return
variables = getattr(query, 'variables', {}) or {}
message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None
bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None
pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None
try:
await monitoring_service.record_tool_call(
tool_name=name,
tool_source=source,
duration=duration_ms,
status=status,
bot_id=getattr(query, 'bot_uuid', None),
bot_name=bot_name,
pipeline_name=pipeline_name,
session_id=self._get_query_session_id(query),
message_id=message_id,
arguments=parameters,
result=result,
error_message=error_message,
)
except Exception as e:
self.ap.logger.warning(f'Failed to record tool call: {e}')
async def _invoke_tool_with_monitoring(
self,
*,
source: str,
name: str,
parameters: dict,
query: pipeline_query.Query,
invoke: typing.Callable[[], typing.Awaitable[typing.Any]],
) -> typing.Any:
start_time = time.perf_counter()
try:
result = await invoke()
except Exception as e:
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='error',
error_message=str(e),
)
raise
duration_ms = int((time.perf_counter() - start_time) * 1000)
await self._record_tool_call(
name=name,
source=source,
parameters=parameters,
query=query,
duration_ms=duration_ms,
status='success',
result=result,
)
return result
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
from langbot.pkg.telemetry import features as telemetry_features
if await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self.native_tool_loader.invoke_tool(name, parameters, query)
return await self._invoke_tool_with_monitoring(
source='native',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query),
)
if await self.plugin_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'plugin')
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
return await self._invoke_tool_with_monitoring(
source='plugin',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
)
if await self.mcp_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
return await self._invoke_tool_with_monitoring(
source='mcp',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
if await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill')
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
return await self._invoke_tool_with_monitoring(
source='skill',
name=name,
parameters=parameters,
query=query,
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
)
raise ToolNotFoundError(name)
async def shutdown(self):
+6
View File
@@ -33,6 +33,12 @@ class VectorDBManager:
self.vector_db = SeekDBVectorDatabase(self.ap)
self.ap.logger.info('Initialized SeekDB vector database backend.')
elif vdb_type == 'valkey_search':
from .vdbs.valkey_search import ValkeySearchVectorDatabase
self.vector_db = ValkeySearchVectorDatabase(self.ap)
self.ap.logger.info('Initialized Valkey Search vector database backend.')
elif vdb_type == 'milvus':
from .vdbs.milvus import MilvusVectorDatabase
@@ -0,0 +1,829 @@
from __future__ import annotations
import asyncio
import json
import struct
from typing import Any
from langbot.pkg.core import app
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
try:
from glide import (
Batch,
GlideClient,
GlideClientConfiguration,
NodeAddress,
RequestError,
ServerCredentials,
ft,
VectorField,
VectorFieldAttributesHnsw,
VectorFieldAttributesFlat,
VectorAlgorithm,
VectorType,
DistanceMetricType,
TagField,
TextField,
FtCreateOptions,
DataType,
FtSearchOptions,
FtSearchLimit,
ReturnField,
)
VALKEY_SEARCH_AVAILABLE = True
except ImportError:
VALKEY_SEARCH_AVAILABLE = False
# Default per-request timeout (ms) for the glide client. The glide library
# default is 250ms, which is too low for vector KNN (``FT.SEARCH ... =>[KNN]``)
# under moderate load or with large indexes and yields spurious TimeoutErrors.
# Overridable via the ``vdb.valkey_search.request_timeout`` config option.
_DEFAULT_REQUEST_TIMEOUT_MS = 5000
# Safety cap on the number of SCAN rounds when purging a collection's keys, so
# a cursor-handling bug or pathological keyspace can never spin forever.
_MAX_SCAN_ROUNDS = 100000
# Mandatory client name for production observability (CLIENT LIST / dashboards).
VALKEY_CLIENT_NAME = 'langbot_vector_client'
# Fixed, indexed metadata schema. LangBot's RAG layer stores ``file_id`` on
# every chunk; it is the only metadata field we promote to a first-class
# (filterable) index field. All other metadata is preserved verbatim inside
# the ``metadata_json`` field so it survives a round-trip, but is NOT
# filterable (the established Milvus / pgvector pragmatism).
_INDEXED_TAG_FIELDS = {'file_id'}
_SUPPORTED_FILTER_FIELDS = set(_INDEXED_TAG_FIELDS)
# Hash field names used for stored documents.
_FIELD_VECTOR = 'vector'
_FIELD_DOCUMENT = 'document'
_FIELD_FILE_ID = 'file_id'
_FIELD_METADATA = 'metadata_json'
_VEC_SCORE_ALIAS = '__vec_score'
# Valkey Search has no bare "match everything" token for non-vector queries
# (a standalone ``*`` is a syntax error). A negated match on a sentinel tag
# value that can never exist matches every key, which is the canonical
# match-all idiom for FT.SEARCH.
_MATCH_ALL = '-@file_id:{__langbot_match_all_sentinel__}'
# Page size used when enumerating matching keys for deletion. Deletes
# paginate through the full result set in batches of this size so that
# files/filters matching more than one page of chunks are fully removed
# (no silent truncation / orphaned vectors).
_DELETE_SCAN_BATCH = 10000
# Characters Valkey Search's TAG query parser cannot handle even when
# backslash-escaped (the brace delimiters and the wildcard). file_id TAG
# values are percent-encoded over this set (plus '%' itself, so the encoding
# is reversible/unambiguous) before being stored or queried, so an arbitrary
# file_id round-trips instead of producing an unparseable query. For normal
# UUID/hash file_ids none of these characters occur, so the encoding is a
# no-op and the stored value is unchanged. The original file_id is always
# preserved verbatim inside ``metadata_json``.
_FT_UNSAFE_TAG_CHARS = frozenset('{}*%')
class ValkeySearchVectorDatabase(VectorDatabase):
"""Valkey Search (valkey-bundle) vector database adapter for LangBot.
Backed by the Valkey Search module shipped in ``valkey/valkey-bundle``,
accessed through the official ``valkey-glide`` client's native ``ft``
(search) command namespace. Documents are stored as Valkey HASH keys
under a per-collection prefix and indexed by one ``FT.CREATE`` index per
collection.
Supported search types: ``VECTOR``, ``FULL_TEXT`` and ``HYBRID``.
Hybrid search semantics (IMPORTANT)
-----------------------------------
Valkey Search hybrid queries follow a *filter-then-KNN* model: the text /
metadata filter pre-selects candidate keys and the KNN stage ranks them by
vector distance. This backend does **NOT** implement application-side
weighted score fusion. The ``vector_weight`` argument is therefore
accepted for interface compatibility but is **not honored** passing
different weights does not change result ordering. A one-time warning is
emitted the first time a non-default weight is supplied. App-side score
fusion can be layered on later if weighted hybrid ranking is required.
"""
@classmethod
def supported_search_types(cls) -> list[SearchType]:
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
def __init__(self, ap: app.Application):
if not VALKEY_SEARCH_AVAILABLE:
raise ImportError(
'valkey-glide is not installed or is unavailable on this platform. '
"On Linux or macOS, install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
)
self.ap = ap
config = self.ap.instance_config.data['vdb']['valkey_search']
self._host = config.get('host', 'localhost')
self._port = int(config.get('port', 6379))
self._db = int(config.get('db', 0))
# Auth / TLS are optional (toB / SaaS). Never logged.
self._password = config.get('password', '') or None
self._username = config.get('username', '') or None
self._tls = bool(config.get('tls', False))
self._request_timeout = int(config.get('request_timeout', _DEFAULT_REQUEST_TIMEOUT_MS))
algorithm = str(config.get('index_algorithm', 'HNSW')).upper()
self._algorithm = VectorAlgorithm.FLAT if algorithm == 'FLAT' else VectorAlgorithm.HNSW
metric = str(config.get('distance_metric', 'COSINE')).upper()
self._distance_metric = {
'COSINE': DistanceMetricType.COSINE,
'L2': DistanceMetricType.L2,
'IP': DistanceMetricType.IP,
}.get(metric, DistanceMetricType.COSINE)
# Lazily-created client (created on first use so a down Valkey does not
# block LangBot boot).
self._client: GlideClient | None = None
# Serializes lazy client creation so concurrent first-use callers do not
# each construct (and leak) a separate GlideClient.
self._client_lock = asyncio.Lock()
# Index names we have already ensured this process lifetime.
self._ensured_indexes: set[str] = set()
# Whether we have already warned about the non-honored vector_weight.
self._vector_weight_warned = False
# ------------------------------------------------------------------ #
# Client lifecycle
# ------------------------------------------------------------------ #
async def _ensure_client(self) -> GlideClient:
"""Create the glide client on first use (lazy, non-blocking boot)."""
if self._client is not None:
return self._client
# Double-checked locking: serialize creation so two concurrent
# first-use callers don't both build a client and leak one.
async with self._client_lock:
if self._client is not None:
return self._client
credentials = None
if self._password is not None:
# username is optional alongside a password (ACL "user" vs default user).
credentials = ServerCredentials(password=self._password, username=self._username)
elif self._username is not None:
# A username without a password is not a valid credential pair, and silently
# connecting unauthenticated to a potentially shared Valkey instance is a
# security footgun (e.g. an env var that failed to resolve). Fail closed.
raise ValueError(
'Valkey Search: a username was configured without a password. '
'Set both username and password to use ACL authentication, or remove both.'
)
conf = GlideClientConfiguration(
addresses=[NodeAddress(self._host, self._port)],
client_name=VALKEY_CLIENT_NAME,
database_id=self._db,
use_tls=self._tls,
lazy_connect=True,
credentials=credentials,
request_timeout=self._request_timeout,
)
self._client = await GlideClient.create(conf)
self.ap.logger.info(
f'Initialized Valkey Search client to {self._host}:{self._port} (db={self._db}, tls={self._tls})'
)
return self._client
async def close(self) -> None:
"""Close the glide client and reset state.
Safe to call when no client was created. After ``close`` the next
operation transparently re-creates the client (``_ensure_client``
guards on ``self._client is None``).
"""
if self._client is not None:
try:
await self._client.close()
except Exception:
self.ap.logger.warning('Valkey Search: error while closing client (ignored)')
finally:
self._client = None
self._ensured_indexes.clear()
# ------------------------------------------------------------------ #
# Naming helpers
# ------------------------------------------------------------------ #
@staticmethod
def _index_name(collection: str) -> str:
return f'idx:{collection}'
@staticmethod
def _key_prefix(collection: str) -> str:
return f'kb:{collection}:'
@staticmethod
def _pack_vector(vec: list[float]) -> bytes:
"""Pack a float vector into little-endian float32 bytes.
Valkey Search stores and queries vectors as FLOAT32 little-endian
blobs (per the search query-language spec).
"""
return struct.pack(f'<{len(vec)}f', *[float(x) for x in vec])
@staticmethod
def _escape_tag(value: str) -> str:
"""Escape characters that are special inside a TAG ``{...}`` clause.
The backslash is escaped first so it cannot consume a following
escape. This neutralises injection-style values (quotes, parens,
``|``, ``@``, ``:``, spaces, dashes) so a crafted ``file_id`` cannot
break out of the clause.
Note: Valkey Search's TAG query parser cannot handle a literal brace
(``{`` / ``}``) or ``*`` even when backslash-escaped. Callers that pass
a ``file_id`` route it through ``_encode_and_escape_tag`` /
``_encode_file_id`` first, which percent-encodes exactly those
characters, so an arbitrary ``file_id`` round-trips safely. This raw
escaper is still correct for all other special characters.
"""
out = []
for ch in str(value):
if ch in '\\,.<>{}[]"\':;!@#$%^&*()-+=~| ':
out.append('\\')
out.append(ch)
return ''.join(out)
@staticmethod
def _encode_file_id(value: str) -> str:
"""Make a ``file_id`` safe to use as an FT TAG token AND query value.
Percent-encodes the characters Valkey Search's TAG parser cannot handle
even when backslash-escaped (``{``, ``}``, ``*``) plus ``%`` itself for
reversibility. Applied identically at write time (the stored TAG field)
and query time (filters / ``delete_by_file_id``) so any value matches
itself. For normal UUID/hash ids none of these characters occur, so
this is a no-op. The original value is always kept verbatim in
``metadata_json``; this encoded form is only ever used for the indexed
TAG.
"""
out = []
for ch in str(value):
if ch in _FT_UNSAFE_TAG_CHARS:
out.append('%{:02X}'.format(ord(ch)))
else:
out.append(ch)
return ''.join(out)
def _encode_and_escape_tag(self, value: str) -> str:
"""Encode an FT-unsafe ``file_id`` then escape TAG special chars."""
return self._escape_tag(self._encode_file_id(value))
# ------------------------------------------------------------------ #
# Filter mapping (canonical triples -> FT query fragment)
# ------------------------------------------------------------------ #
def _triples_to_ft(self, filter: dict[str, Any] | None) -> str:
"""Translate a canonical filter dict into an FT filter expression.
Only indexed fields (``file_id``) are filterable; unsupported fields
are dropped with a warning (matching the Milvus / pgvector pattern).
Returns an empty string when there is no usable filter.
"""
triples = normalize_filter(filter)
if not triples:
return ''
triples = strip_unsupported_fields(triples, _SUPPORTED_FILTER_FIELDS)
fragments: list[str] = []
for field, op, value in triples:
# All currently-indexed fields are TAG fields; file_id values are
# encoded (FT-unsafe chars) then escaped so any value round-trips.
if op == '$eq':
fragments.append(f'@{field}:{{{self._encode_and_escape_tag(value)}}}')
elif op == '$ne':
fragments.append(f'-@{field}:{{{self._encode_and_escape_tag(value)}}}')
elif op == '$in':
joined = '|'.join(self._encode_and_escape_tag(v) for v in value)
fragments.append(f'@{field}:{{{joined}}}')
elif op == '$nin':
joined = '|'.join(self._encode_and_escape_tag(v) for v in value)
fragments.append(f'-@{field}:{{{joined}}}')
elif op == '$gt':
fragments.append(f'@{field}:[({float(value)} +inf]')
elif op == '$gte':
fragments.append(f'@{field}:[{float(value)} +inf]')
elif op == '$lt':
fragments.append(f'@{field}:[-inf ({float(value)}]')
elif op == '$lte':
fragments.append(f'@{field}:[-inf {float(value)}]')
else:
# normalize_filter() already rejects unknown operators, so this
# only triggers if SUPPORTED_OPS grows without this chain being
# updated. Fail closed (rather than silently dropping the
# condition, which would widen delete_by_filter's match set).
raise ValueError(f'Valkey Search: unhandled filter operator {op!r} on field {field!r}')
return ' '.join(fragments)
@staticmethod
def _build_text_clause(text: str) -> str:
"""Build a field-scoped full-text clause for the ``document`` field.
Each whitespace-delimited word becomes a ``@document:<term>`` term and
the terms are AND-ed (space separated). FT special characters in each
term are escaped. Returns an empty string when *text* has no words.
"""
words = [w for w in str(text).split() if w]
if not words:
return ''
terms = [f'@{_FIELD_DOCUMENT}:{ValkeySearchVectorDatabase._escape_text(w)}' for w in words]
return ' '.join(terms)
@staticmethod
def _escape_text(text: str) -> str:
"""Escape FT full-text special characters in a single term."""
out = []
for ch in str(text):
if ch in '@!{}[]()|-"~*:\\':
out.append('\\')
out.append(ch)
return ''.join(out)
# ------------------------------------------------------------------ #
# Index management
# ------------------------------------------------------------------ #
async def _ensure_index(self, client: GlideClient, collection: str, dim: int) -> None:
index = self._index_name(collection)
if index in self._ensured_indexes:
return
# ft.info is O(1) and raises RequestError when the index is absent —
# cheaper than ft.list (O(n) over all indexes) and it closes the
# check-then-create TOCTOU window.
try:
await ft.info(client, index)
self._ensured_indexes.add(index)
return
except RequestError:
pass
if self._algorithm == VectorAlgorithm.FLAT:
vector_attrs = VectorFieldAttributesFlat(
dimensions=dim,
distance_metric=self._distance_metric,
type=VectorType.FLOAT32,
)
else:
vector_attrs = VectorFieldAttributesHnsw(
dimensions=dim,
distance_metric=self._distance_metric,
type=VectorType.FLOAT32,
)
schema = [
VectorField(name=_FIELD_VECTOR, algorithm=self._algorithm, attributes=vector_attrs),
TagField(name=_FIELD_FILE_ID),
TextField(name=_FIELD_DOCUMENT),
]
options = FtCreateOptions(data_type=DataType.HASH, prefixes=[self._key_prefix(collection)])
await ft.create(client, index, schema, options)
self._ensured_indexes.add(index)
self.ap.logger.info(
f"Valkey Search index '{index}' created (dim={dim}, algo={self._algorithm.value}, "
f'metric={self._distance_metric.value})'
)
@staticmethod
def _decode(value: Any) -> str:
if isinstance(value, (bytes, bytearray, memoryview)):
return bytes(value).decode('utf-8', errors='replace')
return str(value)
# ------------------------------------------------------------------ #
# VectorDatabase ABC implementation
# ------------------------------------------------------------------ #
async def get_or_create_collection(self, collection: str):
"""Ensure a client exists.
The index itself requires the vector dimension, which is only known at
first ``add_embeddings`` (same constraint as Qdrant / SeekDB), so this
is a best-effort no-op when the index does not yet exist.
"""
await self._ensure_client()
async def add_embeddings(
self,
collection: str,
ids: list[str],
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
documents: list[str] | None = None,
) -> None:
if not embeddings_list:
return
client = await self._ensure_client()
dim = len(embeddings_list[0])
# The index schema is fixed to the first embedding's dimension. A later
# embedding of a different length would be packed into a wrong-sized
# blob that Valkey stores silently but that yields garbage KNN
# distances, so reject mixed dimensions up-front.
if any(len(e) != dim for e in embeddings_list[1:]):
raise ValueError(f'All embeddings must have dimension {dim}; got mixed lengths')
await self._ensure_index(client, collection, dim)
prefix = self._key_prefix(collection)
batch = Batch(is_atomic=False)
for i, _id in enumerate(ids):
key = prefix + str(_id)
metadata = metadatas[i] if i < len(metadatas) else {}
mapping: dict[str, Any] = {
_FIELD_VECTOR: self._pack_vector(embeddings_list[i]),
_FIELD_METADATA: json.dumps(metadata, ensure_ascii=False),
}
file_id = metadata.get('file_id')
if file_id is not None:
mapping[_FIELD_FILE_ID] = self._encode_file_id(str(file_id))
if documents is not None and i < len(documents) and documents[i] is not None:
mapping[_FIELD_DOCUMENT] = documents[i]
batch.hset(key, mapping)
# Pipeline all HSETs into a single round-trip (non-atomic) instead of
# one await per embedding, which is N sequential round-trips for N
# chunks.
await client.exec(batch, raise_on_error=True)
self.ap.logger.info(f"Added {len(ids)} embeddings to Valkey Search collection '{collection}'")
async def search(
self,
collection: str,
query_embedding: list[float],
k: int = 5,
search_type: str = 'vector',
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
) -> dict[str, Any]:
client = await self._ensure_client()
index = self._index_name(collection)
if not await self._index_exists(client, index):
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
# vector_weight is accepted for interface parity but NOT honored by this
# backend (filter-then-KNN, no weighted fusion). Warn once.
if vector_weight is not None and not self._vector_weight_warned:
self.ap.logger.warning(
'Valkey Search backend does not honor vector_weight: hybrid search uses '
'filter-then-KNN without weighted score fusion. The vector_weight value '
'is ignored. See docs/VALKEY_SEARCH_INTEGRATION.md.'
)
self._vector_weight_warned = True
filter_expr = self._triples_to_ft(filter)
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
text_clause = self._build_text_clause(query_text)
if not text_clause:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
query = f'{filter_expr} {text_clause}'.strip() if filter_expr else text_clause
return await self._run_text_search(client, index, query, k)
if search_type == SearchType.HYBRID:
# Filter / text pre-selects candidates; KNN ranks. No fusion.
pre = filter_expr
if query_text:
text_clause = self._build_text_clause(query_text)
if text_clause:
pre = f'{pre} {text_clause}'.strip() if pre else text_clause
pre = pre or '*'
query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]'
return await self._run_knn_search(client, index, query, query_embedding, k)
# Default: pure VECTOR search.
pre = filter_expr or '*'
query = f'{self._wrap_pre(pre)}=>[KNN {k} @{_FIELD_VECTOR} $BLOB AS {_VEC_SCORE_ALIAS}]'
return await self._run_knn_search(client, index, query, query_embedding, k)
@staticmethod
def _wrap_pre(pre: str) -> str:
"""Parenthesize a multi-condition pre-filter before the ``=>`` KNN clause.
When ``pre`` combines several terms (e.g. ``@file_id:{x} @document:term``)
the Valkey Search parser can otherwise mis-associate only the last term
with the KNN clause. Wrapping the whole expression forces correct
grouping. A bare ``*`` (match-all) and single-term expressions are left
untouched.
"""
if pre and pre != '*' and ' ' in pre.strip():
return f'({pre})'
return pre
async def _run_knn_search(
self,
client: GlideClient,
index: str,
query: str,
query_embedding: list[float],
k: int,
) -> dict[str, Any]:
options = FtSearchOptions(
params={'BLOB': self._pack_vector(list(query_embedding))},
return_fields=[
ReturnField(field_identifier=_VEC_SCORE_ALIAS, alias='distance'),
ReturnField(field_identifier=_FIELD_DOCUMENT),
ReturnField(field_identifier=_FIELD_METADATA),
],
limit=FtSearchLimit(0, k),
dialect=2,
)
try:
reply = await ft.search(client, index, query, options)
except Exception as exc:
if self._is_missing_index_error(exc):
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
raise
return self._reply_to_chroma(index, reply, has_distance=True)
async def _run_text_search(
self,
client: GlideClient,
index: str,
query: str,
k: int,
) -> dict[str, Any]:
options = FtSearchOptions(
return_fields=[
ReturnField(field_identifier=_FIELD_DOCUMENT),
ReturnField(field_identifier=_FIELD_METADATA),
],
limit=FtSearchLimit(0, k),
dialect=2,
)
try:
reply = await ft.search(client, index, query, options)
except Exception as exc:
if self._is_missing_index_error(exc):
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
raise
return self._reply_to_chroma(index, reply, has_distance=False)
@staticmethod
def _is_missing_index_error(exc: Exception) -> bool:
"""Return True if *exc* indicates the FT index does not exist.
``FT.DROPINDEX`` is applied eventually, so an index can briefly still
appear in ``FT._LIST`` after being dropped; a follow-up search then
fails with a "not found" error which we treat as an empty result.
"""
message = str(exc).lower()
return 'not found' in message and 'index' in message
def _iter_reply_docs(self, reply: Any, prefix: str):
"""Yield ``(doc_id, decoded_fields)`` pairs from an FT.SEARCH reply.
glide returns ``[total, {key: {field: value}, ...}]``. This shared
iterator decodes each key, strips the per-collection prefix to recover
the original document id, and decodes the field map the logic both
``_reply_to_chroma`` and ``list_by_filter`` need.
"""
docs = reply[1] if reply and len(reply) >= 2 and isinstance(reply[1], dict) else {}
for key, fields in docs.items():
key_str = self._decode(key)
doc_id = key_str[len(prefix) :] if prefix and key_str.startswith(prefix) else key_str
decoded_fields = {self._decode(fk): fv for fk, fv in fields.items()} if isinstance(fields, dict) else {}
yield doc_id, decoded_fields
def _reply_to_chroma(self, index: str, reply: Any, has_distance: bool) -> dict[str, Any]:
"""Convert an FT.SEARCH reply into Chroma-style nested lists.
The KNN score field (aliased ``distance``) is a COSINE/L2 distance
directly, so no inversion is needed (unlike Qdrant).
"""
ids: list[str] = []
distances: list[float] = []
metadatas: list[dict[str, Any]] = []
if not reply or len(reply) < 2:
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]}
prefix = self._key_prefix(index[len('idx:') :]) if index.startswith('idx:') else ''
for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix):
ids.append(doc_id)
if has_distance and 'distance' in decoded_fields:
try:
distances.append(float(self._decode(decoded_fields['distance'])))
except (TypeError, ValueError):
distances.append(0.0)
else:
distances.append(0.0)
metadata: dict[str, Any] = {}
raw_meta = decoded_fields.get(_FIELD_METADATA)
if raw_meta is not None:
try:
metadata = json.loads(self._decode(raw_meta))
except (TypeError, ValueError):
metadata = {}
metadatas.append(metadata)
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances]}
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
client = await self._ensure_client()
index = self._index_name(collection)
if not await self._index_exists(client, index):
self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion")
return
query = f'@{_FIELD_FILE_ID}:{{{self._encode_and_escape_tag(file_id)}}}'
keys = await self._search_keys(client, index, query)
if keys:
await client.delete(keys)
self.ap.logger.info(
f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' with file_id: {file_id}"
)
async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:
client = await self._ensure_client()
index = self._index_name(collection)
if not await self._index_exists(client, index):
self.ap.logger.warning(f"Valkey Search collection '{collection}' not found for deletion")
return 0
# Guard against accidental mass deletion: a non-empty filter that maps
# to no usable (indexed) conditions must NOT fall back to match-all and
# wipe the whole collection. Skip instead (matching Milvus / pgvector).
query = self._triples_to_ft(filter)
if not query:
self.ap.logger.warning(
"Valkey Search delete_by_filter on '%s': filter produced no usable conditions, skipping",
collection,
)
return 0
keys = await self._search_keys(client, index, query)
if keys:
await client.delete(keys)
self.ap.logger.info(f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' by filter")
return len(keys)
async def list_by_filter(
self,
collection: str,
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
client = await self._ensure_client()
index = self._index_name(collection)
if not await self._index_exists(client, index):
return [], 0
query = self._triples_to_ft(filter) or _MATCH_ALL
options = FtSearchOptions(
return_fields=[
ReturnField(field_identifier=_FIELD_DOCUMENT),
ReturnField(field_identifier=_FIELD_METADATA),
],
limit=FtSearchLimit(offset, limit),
dialect=2,
)
try:
reply = await ft.search(client, index, query, options)
except Exception as exc:
if self._is_missing_index_error(exc):
return [], 0
raise
total = 0
if reply:
try:
total = int(reply[0])
except (TypeError, ValueError):
total = 0
prefix = self._key_prefix(collection)
items: list[dict[str, Any]] = []
for doc_id, decoded_fields in self._iter_reply_docs(reply, prefix):
document = decoded_fields.get(_FIELD_DOCUMENT)
metadata: dict[str, Any] = {}
raw_meta = decoded_fields.get(_FIELD_METADATA)
if raw_meta is not None:
try:
metadata = json.loads(self._decode(raw_meta))
except (TypeError, ValueError):
metadata = {}
items.append(
{
'id': doc_id,
'document': self._decode(document) if document is not None else None,
'metadata': metadata,
}
)
return items, total
async def delete_collection(self, collection: str):
client = await self._ensure_client()
index = self._index_name(collection)
self._ensured_indexes.discard(index)
if await self._index_exists(client, index):
try:
await ft.dropindex(client, index)
except RequestError:
# The index was already dropped (e.g. by a concurrent process)
# between the existence check and this call — benign. Other
# errors (connection / auth) must propagate so the caller knows
# the operation failed rather than silently SCAN-deleting next.
pass
# DROPINDEX does not remove the underlying hashes; delete them too.
prefix = self._key_prefix(collection)
cursor = b'0'
deleted = 0
for _ in range(_MAX_SCAN_ROUNDS):
cursor, keys = await client.scan(cursor, match=f'{prefix}*', count=500)
if keys:
await client.delete(keys)
deleted += len(keys)
if cursor in (b'0', '0', 0):
break
self.ap.logger.info(f"Valkey Search collection '{collection}' deleted ({deleted} keys removed)")
# ------------------------------------------------------------------ #
# Internal search helpers
# ------------------------------------------------------------------ #
async def _index_exists(self, client: GlideClient, index: str) -> bool:
if index in self._ensured_indexes:
return True
# ft.info is O(1) and raises RequestError when the index does not
# exist, vs ft.list which is O(n) over every index on the server and
# was being paid on the first query to each collection.
try:
await ft.info(client, index)
self._ensured_indexes.add(index)
return True
except RequestError:
return False
async def _search_keys(self, client: GlideClient, index: str, query: str) -> list[str]:
"""Return all matching document keys for a query (NOCONTENT).
Paginates through the full result set in pages of ``_DELETE_SCAN_BATCH``
so that queries matching more than one page of chunks are fully
enumerated (avoids silently truncating deletes and leaving orphaned
vectors).
"""
keys: list[str] = []
offset = 0
while True:
options = FtSearchOptions(
nocontent=True,
limit=FtSearchLimit(offset, _DELETE_SCAN_BATCH),
dialect=2,
)
try:
reply = await ft.search(client, index, query, options)
except Exception as exc:
if self._is_missing_index_error(exc):
return keys
raise
if not reply or len(reply) < 2:
break
# reply[0] is the total match count; reply[1] holds this page.
total = 0
try:
total = int(reply[0])
except (TypeError, ValueError):
total = 0
docs = reply[1]
if isinstance(docs, dict):
page = [self._decode(k) for k in docs.keys()]
elif isinstance(docs, (list, tuple)):
page = [self._decode(k) for k in docs]
else:
page = []
if not page:
break
keys.extend(page)
offset += len(page)
if offset >= total or len(page) < _DELETE_SCAN_BATCH:
break
return keys
+19
View File
@@ -87,6 +87,16 @@ vdb:
database: 'langbot'
user: 'postgres'
password: 'postgres'
valkey_search:
host: 'localhost'
port: 6379 # integration tests use 6380 -> valkey/valkey-bundle:9.1.0
db: 0
password: '' # optional (toB auth)
username: '' # optional (ACL user, toB)
tls: false # optional (toB/SaaS)
index_algorithm: 'HNSW' # HNSW | FLAT
distance_metric: 'COSINE' # COSINE | L2 | IP
request_timeout: 5000 # per-request timeout in ms (glide default 250ms is too low for KNN)
storage:
use: local
cleanup:
@@ -143,6 +153,15 @@ box:
- './data/box'
- '/tmp'
workspace_quota_mb: null # Optional disk quota override (>= 0). null = profile default.
# Default nsjail cgroup memory limit for each MCP stdio server process, in MB.
# Node.js MCP servers (npx/bunx) need more memory than Python ones because V8
# and WebAssembly modules (e.g. undici llhttp) reserve large virtual address
# space at startup. Setting this too low causes processes to be killed with
# return_code=137 (OOM kill); the symptom is "Box managed process exited
# unexpectedly" in the logs. Raise on machines with ample RAM; lower only if
# you run exclusively Python (uvx) MCP servers.
# Can also be set via BOX__DEFAULT_MEMORY_MB. Default: 1536.
default_memory_mb: 1536
docker:
cpu_limit_enabled: true # When false, Docker sandbox containers are started without --cpus. Memory and PID limits still apply.
e2b:
+28 -14
View File
@@ -118,20 +118,6 @@ stages:
default:
- role: system
content: "You are a helpful assistant."
- name: knowledge-bases
label:
en_US: Knowledge Bases
zh_Hans: 知识库
description:
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
type: knowledge-base-multi-selector
required: false
default: []
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: box-session-id-template
label:
en_US: Sandbox Scope
@@ -254,6 +240,34 @@ stages:
field: rerank-model
operator: neq
value: ''
- name: tools
label:
en_US: Tools
zh_Hans: 工具
description:
en_US: Select plugin, MCP, skill, and built-in tools available to this Local Agent.
zh_Hans: 选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。
type: rich-tools-selector
required: false
default: []
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: knowledge-bases
label:
en_US: Resources
zh_Hans: 资源
description:
en_US: Select MCP resources and knowledge bases available to this Local Agent.
zh_Hans: 选择此内置 Agent 可以读取的 MCP 资源和知识库。
type: resources-selector
required: false
default: []
show_if:
field: __system.is_wizard
operator: neq
value: true
- name: dify-service-api
label:
en_US: Dify Service API
+11
View File
@@ -104,6 +104,17 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
'user': 'postgres',
'password': 'postgres',
},
'valkey_search': {
'host': 'localhost',
'port': 6379,
'db': 0,
'password': '',
'username': '',
'tls': False,
'index_algorithm': 'HNSW',
'distance_metric': 'COSINE',
'request_timeout': 5000,
},
},
'storage': {
'use': 'local',
+1
View File
@@ -81,6 +81,7 @@ def fake_monitoring_app():
)
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5))
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
@@ -0,0 +1,344 @@
"""Integration tests for the Valkey Search VDB backend.
These are SLOW, real-server tests. They are gated on ``TEST_VALKEY_URL`` and
skipped when it is unset (same precedent as the PostgreSQL migration tests).
Run locally against valkey/valkey-bundle:9.1.0::
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
TEST_VALKEY_URL=valkey://localhost:6380 \\
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
The default upstream fast CI lane (``-m "not slow"``) skips these; the local
supervisor validator MUST run them.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from types import SimpleNamespace
from urllib.parse import urlparse
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.slow]
def _parse_valkey_url(url: str) -> tuple[str, int, int]:
"""Parse ``valkey://host:port/db`` into ``(host, port, db)``."""
parsed = urlparse(url)
host = parsed.hostname or 'localhost'
port = parsed.port or 6379
db = 0
if parsed.path and parsed.path.strip('/'):
try:
db = int(parsed.path.strip('/'))
except ValueError:
db = 0
return host, port, db
@pytest.fixture
def valkey_config():
url = os.environ.get('TEST_VALKEY_URL')
if not url:
pytest.skip('TEST_VALKEY_URL not set')
host, port, db = _parse_valkey_url(url)
return {
'host': host,
'port': port,
'db': db,
'password': '',
'username': '',
'tls': False,
'index_algorithm': 'HNSW',
'distance_metric': 'COSINE',
}
def _make_ap(valkey_config):
"""Build a minimal fake ``ap`` with the config + a no-op logger."""
logger = SimpleNamespace(
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
debug=lambda *a, **k: None,
)
instance_config = SimpleNamespace(data={'vdb': {'valkey_search': valkey_config}})
return SimpleNamespace(instance_config=instance_config, logger=logger)
@pytest.fixture
async def backend(valkey_config):
"""Create a Valkey Search backend, skip if module/server unavailable."""
from langbot.pkg.vector.vdbs.valkey_search import (
ValkeySearchVectorDatabase,
VALKEY_SEARCH_AVAILABLE,
)
if not VALKEY_SEARCH_AVAILABLE:
pytest.skip('valkey-glide not installed')
from glide import ft
ap = _make_ap(valkey_config)
db = ValkeySearchVectorDatabase(ap)
client = await db._ensure_client()
# Module-presence gate: FT.LIST must be available (Search module loaded).
try:
await ft.list(client)
except Exception as exc: # noqa: BLE001
await client.close()
pytest.skip(f'Valkey Search module not available: {exc}')
collection = f'test_{uuid.uuid4().hex[:12]}'
yield db, collection
# Cleanup
try:
await db.delete_collection(collection)
except Exception:
pass
if db._client is not None:
await db._client.close()
async def _poll_until(coro_factory, predicate, timeout=5.0, interval=0.2):
"""Poll an async result until predicate is true (indexer is async)."""
deadline = asyncio.get_event_loop().time() + timeout
result = await coro_factory()
while not predicate(result) and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(interval)
result = await coro_factory()
return result
def _sample_docs():
ids = ['d1', 'd2', 'd3']
embeddings = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.9, 0.1, 0.0, 0.0],
]
metadatas = [
{'file_id': 'fileA', 'topic': 'cats'},
{'file_id': 'fileB', 'topic': 'dogs'},
{'file_id': 'fileA', 'topic': 'cats'},
]
documents = [
'the quick brown fox',
'lazy dogs sleeping',
'foxes and cats playing',
]
return ids, embeddings, metadatas, documents
@pytest.mark.asyncio
async def test_add_and_vector_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector'),
lambda r: len(r['ids'][0]) >= 1,
)
assert len(result['ids'][0]) >= 1
# Closest to [1,0,0,0] should be d1.
assert result['ids'][0][0] == 'd1'
assert all(isinstance(d, float) for d in result['distances'][0])
@pytest.mark.asyncio
async def test_full_text_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [0.0, 0.0, 0.0, 0.0], k=5, search_type='full_text', query_text='dogs'),
lambda r: len(r['ids'][0]) >= 1,
)
assert 'd2' in result['ids'][0]
@pytest.mark.asyncio
async def test_hybrid_filter_then_knn(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(
collection,
[1.0, 0.0, 0.0, 0.0],
k=5,
search_type='hybrid',
query_text='cats',
filter={'file_id': 'fileA'},
),
lambda r: len(r['ids'][0]) >= 1,
)
# Only fileA docs (d1, d3) should be candidates.
assert set(result['ids'][0]).issubset({'d1', 'd3'})
@pytest.mark.asyncio
async def test_vector_weight_not_honored(backend):
"""Passing different vector_weight values must NOT change ranking."""
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
common = dict(
collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats'
)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1)
r_low = await db.search(**common, vector_weight=0.1)
r_high = await db.search(**common, vector_weight=0.9)
assert r_low['ids'][0] == r_high['ids'][0]
@pytest.mark.asyncio
async def test_filter_operators(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
# Wait for indexing.
await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] >= 3,
)
# $eq
items, total = await db.list_by_filter(collection, filter={'file_id': 'fileA'})
assert total == 2
assert {it['id'] for it in items} == {'d1', 'd3'}
# $ne
items, total = await db.list_by_filter(collection, filter={'file_id': {'$ne': 'fileA'}})
assert {it['id'] for it in items} == {'d2'}
# $in
items, total = await db.list_by_filter(collection, filter={'file_id': {'$in': ['fileA', 'fileB']}})
assert total == 3
# $nin
items, total = await db.list_by_filter(collection, filter={'file_id': {'$nin': ['fileB']}})
assert {it['id'] for it in items} == {'d1', 'd3'}
@pytest.mark.asyncio
async def test_delete_by_file_id(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
await db.delete_by_file_id(collection, 'fileA')
items, total = await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] <= 1,
)
assert {it['id'] for it in items} == {'d2'}
@pytest.mark.asyncio
async def test_delete_by_filter_returns_count(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
assert deleted == 2
@pytest.mark.asyncio
async def test_list_by_filter_pagination(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
page1, total = await db.list_by_filter(collection, limit=2, offset=0)
assert total == 3
assert len(page1) == 2
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
assert total == 3
assert len(page2) == 1
@pytest.mark.asyncio
async def test_delete_collection(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
await db.delete_collection(collection)
# After dropping, search on a missing index returns empty.
result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector')
assert result['ids'][0] == []
@pytest.mark.asyncio
async def test_adversarial_filter_and_query_input(backend):
"""Crafted FT special chars in file_id / query_text must not break out.
Guarantees locked in here:
* A file_id full of injection-style chars (quotes, parens, ``|``, ``@``,
``:``, spaces, dashes) only ever matches its own row the payload is
escaped to literal TAG content, never interpreted as extra clauses.
* A query_text full of FT operators does not raise and does not widen the
result set.
* A file_id containing FT-unsafe chars (``{`` / ``}`` / ``*``) is
percent-encoded, so it round-trips correctly: an exact match returns ONLY
its own row and never widens to an unrelated row, and the query does not
raise.
"""
db, collection = backend
# Injection-style file_id WITHOUT FT-unsafe chars (the realistic surface).
injection_fid = 'evil") @file_id (".id|x-y:z'
# file_id WITH FT-unsafe chars that previously could not be queried.
brace_fid = 'x} @file_id:{*'
ids = ['adv1', 'benign2', 'brace3']
embeddings = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
metadatas = [{'file_id': injection_fid}, {'file_id': 'plainB'}, {'file_id': brace_fid}]
documents = ['payload row content', 'unrelated benign content', 'brace row content']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
# Exact-match on the crafted file_id returns ONLY its own row.
items, total = await db.list_by_filter(collection, filter={'file_id': injection_fid})
assert total == 1
assert {it['id'] for it in items} == {'adv1'}
# A query_text packed with FT operators must not raise and must not match
# the benign row (escaped to literal terms, none of which it contains).
result = await db.search(
collection,
[0.0, 0.0, 0.0, 0.0],
k=5,
search_type='full_text',
query_text='@document:{*} | -()~ "evil"',
)
assert 'benign2' not in result['ids'][0]
# The brace/star-bearing file_id is encoded, so it round-trips: exact match
# returns ONLY its own row and never widens. No RequestError is raised.
b_items, b_total = await db.list_by_filter(collection, filter={'file_id': brace_fid})
assert b_total == 1
assert {it['id'] for it in b_items} == {'brace3'}
# And deletion by that file_id removes exactly its own row.
deleted = await db.delete_by_filter(collection, filter={'file_id': brace_fid})
assert deleted == 1
+1 -1
View File
@@ -27,7 +27,7 @@
### 4. 向量数据库 (`vector/vdbs/`)
- **路径**: `src/langbot/pkg/vector/vdbs/`
- **模块**: chroma, milvus, pgvector, qdrant, seekdb
- **模块**: chroma, milvus, pgvector, qdrant, seekdb, valkey_search
- **排除原因**: 需要真实向量数据库实例运行
- **测试方式**: 需要 Docker 启动测试数据库或 mock
- **状态**: 后续可补充 mock 测试
@@ -90,6 +90,56 @@ class TestMCPServiceGetRuntimeInfo:
assert result is None
class TestMCPServiceResources:
"""Tests for MCP resource helpers."""
async def test_get_resource_templates_delegates_to_loader(self):
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock(
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
)
service = MCPService(ap)
result = await service.get_mcp_server_resource_templates('docs')
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
async def test_read_resource_envelope_uses_ui_preview_source(self):
ap = SimpleNamespace()
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock(
return_value={
'server_name': 'docs',
'uri': 'file:///README.md',
'contents': [],
'source': 'ui_preview',
}
)
service = MCPService(ap)
result = await service.read_mcp_server_resource_envelope(
'docs',
'file:///README.md',
max_bytes=4096,
include_blob=True,
)
assert result['source'] == 'ui_preview'
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
'docs',
'file:///README.md',
include_blob=True,
source='ui_preview',
max_bytes=4096,
)
class TestMCPServiceGetMCPServers:
"""Tests for get_mcp_servers method."""
@@ -230,6 +280,25 @@ class TestMCPServiceCreateMCPServer:
assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format
async def test_create_mcp_server_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
existing_server = _create_mock_mcp_server(name='Existing Server')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = MCPService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
await service.create_mcp_server({'name': 'Existing Server'})
async def test_create_mcp_server_loads_server(self):
"""Loads server into tool_mgr when enabled."""
# Setup
@@ -251,7 +320,7 @@ class TestMCPServiceCreateMCPServer:
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result([]) # Empty list for limit check
return _create_mock_result([]) # Empty result for duplicate-name check
elif call_count == 2:
return Mock() # Insert
return _create_mock_result(first_item=server_entity) # Select created
@@ -348,6 +348,8 @@ class TestPipelineServiceCreatePipeline:
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [],
'mcp_resource_agent_read_enabled': True,
}
@@ -814,6 +816,47 @@ class TestPipelineServiceUpdatePipelineExtensions:
# Verify - persistence was called
ap.persistence_mgr.execute_async.assert_called()
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
ap.pipeline_mgr.load_pipeline = AsyncMock()
original_pipeline = _create_mock_pipeline(
extensions_preferences={
'enable_all_plugins': True,
'enable_all_mcp_servers': True,
'plugins': [],
'mcp_servers': [],
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
'mcp_resource_agent_read_enabled': False,
}
)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=original_pipeline)
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
assert original_pipeline.extensions_preferences['mcp_resources'] == [
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
]
class TestDefaultStageOrder:
"""Tests for default_stage_order constant."""
@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(mcp_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
group = MCPRouterGroup(ap, app)
await group.initialize()
return app.test_client()
async def test_mcp_server_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(
return_value={
'uuid': 'test-uuid',
'name': 'pab1it0/prometheus',
'enable': True,
'mode': 'stdio',
'extra_args': {},
}
)
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
async def test_mcp_resource_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(),
get_mcp_server_resources=AsyncMock(return_value=[]),
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_not_awaited()
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['resource_capabilities'] == {'subscribe': False}
@@ -162,3 +162,46 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
# Verify stage was called
mock_stage.process.assert_called_once()
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
"""Local Agent resource selection should override legacy extension prefs."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {
'ai': {
'local-agent': {
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
'mcp-resource-agent-read-enabled': False,
}
}
}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': True,
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
"""Existing extension prefs remain compatible until a Local Agent value exists."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.config = {'ai': {'local-agent': {}}}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': False,
}
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
+58
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -431,3 +432,60 @@ class TestPreProcessorVariables:
variables = result.new_query.variables
assert 'group_name' in variables
assert 'sender_name' in variables
class TestPreProcessorToolSelection:
"""Tests for Local Agent tool selection."""
@pytest.mark.asyncio
async def test_local_agent_filters_selected_tools(self):
"""Only selected tools should be exposed when all-tools mode is off."""
preproc = get_preproc_module()
app = FakeApp()
mock_session = Mock()
mock_session.launcher_type = Mock(value='person')
mock_session.launcher_id = 12345
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
mock_conversation.prompt = Mock(messages=[])
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
mock_conversation.messages = []
mock_conversation.uuid = None
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
app.tool_mgr.get_all_tools = AsyncMock(
return_value=[
SimpleNamespace(name='exec'),
SimpleNamespace(name='plugin_tool'),
SimpleNamespace(name='mcp_tool'),
]
)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
query = text_query('hello')
query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
'prompt': 'default',
'enable-all-tools': False,
'tools': ['plugin_tool'],
},
},
'output': {'misc': {'at-sender': False}},
'trigger': {'misc': {}},
}
result = await stage.process(query, 'PreProcessor')
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
@@ -0,0 +1,538 @@
import pytest
import aiocqhttp
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.aiocqhttp import (
AiocqhttpAdapter,
AiocqhttpEventConverter,
AiocqhttpMessageConverter,
)
async def _convert_single(component: platform_message.MessageComponent):
chain = platform_message.MessageChain([component])
message, _, _ = await AiocqhttpMessageConverter.yiri2target(chain)
return message[0]
@pytest.mark.asyncio
@pytest.mark.parametrize(
('payload', 'expected'),
[
('data:image/jpeg;base64,raw-image', 'base64://raw-image'),
('raw-image', 'base64://raw-image'),
('base64://raw-image', 'base64://raw-image'),
],
)
async def test_image_base64_payload_is_normalized(payload, expected):
segment = await _convert_single(platform_message.Image(base64=payload))
assert segment.type == 'image'
assert segment.data['file'] == expected
@pytest.mark.asyncio
async def test_voice_data_uri_base64_payload_is_normalized():
segment = await _convert_single(platform_message.Voice(base64='data:audio/wav;base64,raw-voice'))
assert segment.type == 'record'
assert segment.data['file'] == 'base64://raw-voice'
@pytest.mark.asyncio
@pytest.mark.parametrize(
('component', 'expected'),
[
(
platform_message.File(name='report.txt', base64='data:text/plain;base64,raw-file'),
{'file': 'base64://raw-file', 'name': 'report.txt'},
),
(
platform_message.File(name='report.txt', base64='raw-file'),
{'file': 'base64://raw-file', 'name': 'report.txt'},
),
(
platform_message.File(name='a.txt', url='http://example.com/a.txt'),
{'file': 'http://example.com/a.txt', 'name': 'a.txt'},
),
(
platform_message.File(name='a.txt', path='/tmp/a.txt'),
{'file': '/tmp/a.txt', 'name': 'a.txt'},
),
],
)
async def test_file_message_uses_available_file_source(component, expected):
segment = await _convert_single(component)
assert segment.type == 'file'
assert segment.data == expected
@pytest.mark.asyncio
async def test_forward_image_base64_payload_is_normalized():
forward = platform_message.Forward(
node_list=[
platform_message.ForwardMessageNode(
sender_id='10001',
sender_name='Tester',
message_chain=platform_message.MessageChain(
[platform_message.Image(base64='data:image/png;base64,raw-forward-image')]
),
)
]
)
messages = []
class Logger:
async def info(self, _message):
return None
async def error(self, _message):
return None
class Bot:
async def call_action(self, action, **kwargs):
assert action == 'send_forward_msg'
messages.append(kwargs)
platform = AiocqhttpAdapter.model_construct(
bot_account_id='10000',
config={},
logger=Logger(),
bot=Bot(),
)
await platform._send_forward_message(1000, forward)
assert messages[0]['messages'][0]['data']['content'][0] == {
'type': 'image',
'data': {'file': 'base64://raw-forward-image'},
}
@pytest.mark.asyncio
async def test_group_message_member_name_prefers_group_card():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Special Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Test Group'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.member_name == 'Group Card'
assert converted.sender.group.id == 2000
assert converted.sender.group.name == 'Test Group'
assert converted.sender.special_title == 'Special Title'
@pytest.mark.asyncio
async def test_group_message_member_name_falls_back_to_nickname():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': '',
'role': 'member',
},
}
)
converted = await AiocqhttpEventConverter().target2yiri(event)
assert converted.sender.member_name == 'QQ Nickname'
@pytest.mark.asyncio
async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
assert group_id == 2000
assert user_id == 3000
return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'}
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Member Title'
@pytest.mark.asyncio
async def test_group_message_special_title_does_not_lookup_when_sender_title_exists():
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': 'Event Title',
},
}
)
class Bot:
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
raise AssertionError('get_group_member_info should not be called')
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
assert converted.sender.special_title == 'Event Title'
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == ''
assert second.sender.special_title == ''
assert bot.member_info_calls == 1
@pytest.mark.asyncio
async def test_group_message_special_title_member_info_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
return {
'group_id': group_id,
'user_id': user_id,
'title': f'Member Title {self.member_info_calls}',
}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 87401.0
second = await converter.target2yiri(event, bot)
assert first.sender.special_title == 'Member Title 1'
assert second.sender.special_title == 'Member Title 2'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
'title': '',
},
}
)
now = 1000.0
class Bot:
member_info_calls = 0
async def get_group_info(self, group_id):
return {'group_id': group_id, 'group_name': 'Test Group'}
async def get_group_member_info(self, group_id, user_id):
self.member_info_calls += 1
if self.member_info_calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1601.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.special_title == ''
assert recovered.sender.special_title == 'Recovered Title'
assert bot.member_info_calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_is_cached(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
assert group_id == 2000
return {'group_id': group_id, 'group_name': 'Cached Group'}
monotonic = 1000.0
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Cached Group'
assert second.sender.group.name == 'Cached Group'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
first = await converter.target2yiri(event, bot)
now = 4601.0
second = await converter.target2yiri(event, bot)
assert first.sender.group.name == 'Group Name 1'
assert second.sender.group.name == 'Group Name 2'
assert bot.calls == 2
@pytest.mark.asyncio
async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
raise RuntimeError('api unavailable')
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
converted = await converter.target2yiri(event, bot)
cached_failure = await converter.target2yiri(event, bot)
assert converted.sender.group.name == 'Group 2000'
assert cached_failure.sender.group.name == 'Group 2000'
assert bot.calls == 1
@pytest.mark.asyncio
async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch):
event = aiocqhttp.Event(
{
'post_type': 'message',
'message_type': 'group',
'message_id': 1000,
'message': '',
'time': 1776491725,
'group_id': 2000,
'sender': {
'user_id': 3000,
'nickname': 'QQ Nickname',
'card': 'Group Card',
'role': 'member',
},
}
)
now = 1000.0
class Bot:
calls = 0
async def get_group_info(self, group_id):
self.calls += 1
if self.calls == 1:
raise RuntimeError('api unavailable')
return {'group_id': group_id, 'group_name': 'Recovered Group'}
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
bot = Bot()
converter = AiocqhttpEventConverter()
failed = await converter.target2yiri(event, bot)
now = 1061.0
recovered = await converter.target2yiri(event, bot)
assert failed.sender.group.name == 'Group 2000'
assert recovered.sender.group.name == 'Recovered Group'
assert bot.calls == 2
@@ -0,0 +1,91 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter
class DummyLogger(abstract_platform_logger.AbstractEventLogger):
async def info(self, *args, **kwargs):
pass
async def debug(self, *args, **kwargs):
pass
async def warning(self, *args, **kwargs):
pass
async def error(self, *args, **kwargs):
pass
def make_adapter():
return WecomCSAdapter(
config={
'corpid': 'corp-id',
'secret': 'secret',
'token': 'token',
'EncodingAESKey': 'encoding-key',
},
logger=DummyLogger(),
)
@pytest.mark.asyncio
async def test_send_message_sends_text_to_customer_service_user():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_text_msg.assert_awaited_once()
kwargs = adapter.bot.send_text_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user'
assert kwargs['content'] == 'hello'
assert kwargs['msgid'].startswith('langbot_')
@pytest.mark.asyncio
async def test_send_message_allows_explicit_open_kfid_in_target_id():
adapter = make_adapter()
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'kf-explicit|uexternal-user', message)
kwargs = adapter.bot.send_text_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-explicit'
assert kwargs['external_userid'] == 'external-user'
@pytest.mark.asyncio
async def test_send_message_requires_open_kfid():
adapter = make_adapter()
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
with pytest.raises(ValueError, match='open_kfid is required'):
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_text_msg.assert_not_called()
@pytest.mark.asyncio
async def test_send_message_rejects_group_targets():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
with pytest.raises(ValueError, match='only supports sending messages to person'):
await adapter.send_message('group', 'group-id', message)
adapter.bot.send_text_msg.assert_not_called()
@@ -2,7 +2,7 @@
import pytest
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
from langbot.pkg.plugin.connector import PluginRuntimeConnector
def test_parse_plugin_id_accepts_author_name():
@@ -417,7 +417,7 @@ class TestBuildBoxSessionPayload:
payload = s._build_box_session_payload('session-123')
assert payload['image'] == 'node:20'
assert payload['cpus'] == 2.0
assert payload['memory_mb'] == 1024
assert payload["memory_mb"] == 1024
assert payload['pids_limit'] == 256
def test_none_fields_excluded(self, mcp_module):
@@ -639,10 +639,13 @@ class TestGetRuntimeInfoDict:
assert info['box_session_id'] == 'mcp-shared'
assert info['box_enabled'] is True
def test_transient_test_session_is_isolated_from_shared(self, mcp_module):
"""A transient test session (config-page "test", no persisted UUID)
must NOT share the live "mcp-shared" Box session. Regression: a failing
test churned the shared session and tore down healthy live servers."""
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
"""A transient config-page "test" now shares the same 'mcp-shared' Box
session as live servers (so a test reuses the running container / live
process instead of a cold per-test session bootstrap). Isolation is at
the PROCESS level: the test runs under its own process_id and only ever
stops that process_id, so it cannot disturb another server's live
process or the shared session itself."""
ap = _make_ap()
ap.box_service.available = True
transient = _make_session(
@@ -670,10 +673,12 @@ class TestGetRuntimeInfoDict:
)
assert transient.is_transient is True
assert live.is_transient is False
# Isolated session id for the test, shared for the live server.
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123'
# Both share ONE Box session ...
assert transient._build_box_session_id() == 'mcp-shared'
assert live._build_box_session_id() == 'mcp-shared'
assert transient._build_box_session_id() != live._build_box_session_id()
assert transient._build_box_session_id() == live._build_box_session_id()
# ... but are isolated by distinct process_ids within that session.
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
"""Policy: when Box is configured but unavailable (disabled in config
@@ -824,3 +829,129 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
assert process_payload['command'] == 'python'
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
@pytest.mark.asyncio
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
"""During a slow (npx) cold start the handshake fails while the managed
process is still alive. initialize() must raise _ColdStartRetry (so the
outer lifecycle loop reuses the live process and retries without stopping it
or consuming the fatal budget), NOT a fatal error."""
from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
class ColdClientSession:
def __init__(self, *_args):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def initialize(self):
# Process still cold-starting: handshake fails.
raise Exception('Connection closed')
@asynccontextmanager
async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap()
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
session = _make_session(
mcp_module,
{
'name': 'slow',
'uuid': 'slow-uuid',
'mode': 'stdio',
'command': 'npx',
'args': ['-y', 'some-mcp'],
},
ap=ap,
)
# Process is NOT exited (still cold-starting) and not yet running for reuse.
async def _not_exited():
return False
session._box_stdio_runtime._managed_process_has_exited = _not_exited
async def _not_running():
return False
session._box_stdio_runtime._managed_process_is_running = _not_running
with pytest.raises(mcp_stdio_module._ColdStartRetry):
await session._init_box_stdio_server()
# Process was started exactly once (the retry will reuse it, not rebuild).
assert ap.box_service.start_managed_process.await_count == 1
await session.exit_stack.aclose()
@pytest.mark.asyncio
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
"""If the handshake fails AND the process has definitively exited, that is a
real failure initialize() must NOT swallow it as a cold-start retry."""
from contextlib import asynccontextmanager
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
class DeadClientSession:
def __init__(self, *_args):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def initialize(self):
raise Exception('Connection closed')
@asynccontextmanager
async def fake_websocket_client(_url: str):
yield ('read-stream', 'write-stream')
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
ap = _make_ap()
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
session = _make_session(
mcp_module,
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
ap=ap,
)
async def _exited():
return True
session._box_stdio_runtime._managed_process_has_exited = _exited
async def _not_running():
return False
session._box_stdio_runtime._managed_process_is_running = _not_running
with pytest.raises(Exception) as ei:
await session._init_box_stdio_server()
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
await session.exit_stack.aclose()
@@ -0,0 +1,244 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from aiohttp import web
from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
class _TransportProbe:
def __init__(self, streamable_status: int | None) -> None:
self.streamable_status = streamable_status
self.streamable_posts = 0
self.streamable_messages: list[str] = []
self.sse_gets = 0
self.sse_messages: list[str] = []
self.streamable_request_started = asyncio.Event()
self.release_streamable_request = asyncio.Event()
self._sse_response: web.StreamResponse | None = None
async def handle_mcp_endpoint(self, request: web.Request) -> web.StreamResponse:
if request.method == 'POST':
self.streamable_posts += 1
self.streamable_request_started.set()
if self.streamable_status is None:
await self.release_streamable_request.wait()
return web.Response(status=204)
if self.streamable_status == 200:
message = await request.json()
method = message.get('method', '')
self.streamable_messages.append(method)
if method == 'initialize':
return web.json_response(
{
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
'capabilities': {'tools': {}},
'serverInfo': {'name': 'streamable-test', 'version': '1.0.0'},
},
}
)
if method == 'tools/list':
return web.json_response(
{
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'tools': [
{
'name': 'echo',
'description': 'Echo test input',
'inputSchema': {'type': 'object'},
}
]
},
}
)
return web.Response(status=202)
return web.Response(status=self.streamable_status)
self.sse_gets += 1
response = web.StreamResponse(
status=200,
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
)
await response.prepare(request)
self._sse_response = response
await response.write(b'event: endpoint\ndata: /messages?session_id=test-session\n\n')
try:
while request.transport is not None and not request.transport.is_closing():
await asyncio.sleep(0.05)
except asyncio.CancelledError:
raise
return response
async def handle_sse_message(self, request: web.Request) -> web.Response:
message = await request.json()
method = message.get('method', '')
self.sse_messages.append(method)
if method == 'initialize':
response_message = {
'jsonrpc': '2.0',
'id': message['id'],
'result': {
'protocolVersion': mcp_types.LATEST_PROTOCOL_VERSION,
'capabilities': {},
'serverInfo': {'name': 'legacy-sse-test', 'version': '1.0.0'},
},
}
assert self._sse_response is not None
payload = json.dumps(response_message, separators=(',', ':'))
await self._sse_response.write(f'event: message\ndata: {payload}\n\n'.encode())
return web.Response(status=202)
@asynccontextmanager
async def _transport_server(streamable_status: int | None):
probe = _TransportProbe(streamable_status)
application = web.Application()
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
application.router.add_post('/messages', probe.handle_sse_message)
runner = web.AppRunner(application, shutdown_timeout=0.1)
await runner.setup()
site = web.TCPSite(runner, '127.0.0.1', 0)
await site.start()
server = cast(asyncio.Server, site._server)
port = server.sockets[0].getsockname()[1]
try:
yield probe, f'http://127.0.0.1:{port}/mcp'
finally:
await runner.cleanup()
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
app = cast(Any, SimpleNamespace(logger=Mock()))
return RuntimeMCPSession(
'remote-transport-test',
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
True,
app,
)
def _contains_http_status(exc: BaseException, status_code: int) -> bool:
return any(
isinstance(leaf, httpx.HTTPStatusError) and leaf.response.status_code == status_code
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
)
async def _close_session(session: RuntimeMCPSession) -> None:
await session.exit_stack.aclose()
@pytest.mark.asyncio
async def test_remote_transport_real_streamable_http_success_keeps_session_usable():
async with _transport_server(200) as (probe, url):
session = _session(url)
try:
await session._init_remote_server()
assert session.session is not None
tools = await session.session.list_tools()
assert [tool.name for tool in tools.tools] == ['echo']
assert probe.streamable_posts >= 2
assert probe.streamable_messages[:2] == ['initialize', 'notifications/initialized']
assert 'tools/list' in probe.streamable_messages
assert probe.sse_gets == 0
finally:
await _close_session(session)
@pytest.mark.asyncio
@pytest.mark.parametrize('status_code', [400, 404, 405])
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
async with _transport_server(status_code) as (probe, url):
session = _session(url)
try:
await session._init_remote_server()
assert session.session is not None
assert probe.streamable_posts == 1
assert probe.sse_gets == 1
assert 'initialize' in probe.sse_messages
finally:
await _close_session(session)
@pytest.mark.asyncio
@pytest.mark.parametrize('status_code', [401, 403, 406, 415, 429, 500])
async def test_remote_transport_real_non_compatibility_error_does_not_fallback(status_code: int):
async with _transport_server(status_code) as (probe, url):
session = _session(url)
try:
with pytest.raises(BaseException) as exc_info:
await session._init_remote_server()
assert _contains_http_status(exc_info.value, status_code)
assert probe.streamable_posts == 1
assert probe.sse_gets == 0
finally:
await _close_session(session)
@pytest.mark.asyncio
async def test_remote_transport_real_timeout_does_not_fallback():
async with _transport_server(None) as (probe, url):
session = _session(url, timeout=0.05)
try:
with pytest.raises(BaseException) as exc_info:
await session._init_remote_server()
assert any(
isinstance(leaf, httpx.TimeoutException)
for leaf in RuntimeMCPSession._iter_exception_leaves(exc_info.value)
)
assert probe.streamable_posts == 1
assert probe.sse_gets == 0
finally:
probe.release_streamable_request.set()
await _close_session(session)
@pytest.mark.asyncio
@pytest.mark.parametrize('error_type', [httpx.ConnectError, httpx.ConnectTimeout])
async def test_remote_transport_connection_errors_do_not_fallback(error_type: type[httpx.RequestError]):
request = httpx.Request('POST', 'https://unreachable.invalid/mcp')
error = error_type('connection failed', request=request)
session = _session(str(request.url))
session._init_streamable_http_server = AsyncMock(side_effect=error)
session._init_sse_server = AsyncMock()
with pytest.raises(type(error)) as exc_info:
await session._init_remote_server()
assert exc_info.value is error
session._init_sse_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fallback():
async with _transport_server(None) as (probe, url):
session = _session(url)
task = asyncio.create_task(session._init_remote_server())
await asyncio.wait_for(probe.streamable_request_started.wait(), timeout=2)
task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await task
assert probe.sse_gets == 0
finally:
probe.release_streamable_request.set()
await _close_session(session)
@@ -0,0 +1,323 @@
from __future__ import annotations
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from mcp import types as mcp_types
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY,
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
MCPLoader,
MCPSessionStatus,
RuntimeMCPSession,
)
from langbot.pkg.telemetry import features as telemetry_features
def _app() -> SimpleNamespace:
return SimpleNamespace(logger=Mock())
def _connected_session(
*,
name: str = 'docs',
uuid: str = 'srv-1',
resources: list[dict] | None = None,
templates: list[dict] | None = None,
) -> RuntimeMCPSession:
session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app())
session.status = MCPSessionStatus.CONNECTED
session.session = SimpleNamespace(read_resource=AsyncMock())
session.resources = resources or [
{
'uri': 'file:///README.md',
'name': 'README.md',
'title': '',
'description': '',
'mime_type': 'text/markdown',
'size': None,
'icons': [],
'annotations': {},
'_meta': {},
}
]
session.resource_templates = templates or []
return session
def _query() -> SimpleNamespace:
return SimpleNamespace(variables={})
def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
request = httpx.Request('POST', 'https://example.com/mcp')
response = httpx.Response(status_code, request=request)
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
@pytest.mark.asyncio
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
session = RuntimeMCPSession(
'remote',
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
True,
_app(),
)
session._init_streamable_http_server = AsyncMock(
side_effect=ExceptionGroup('transport failed', [_http_status_error(405)])
)
session._init_sse_server = AsyncMock()
await session._init_remote_server()
session._init_streamable_http_server.assert_awaited_once()
session._init_sse_server.assert_awaited_once()
@pytest.mark.asyncio
async def test_remote_transport_does_not_fallback_for_auth_http_status():
session = RuntimeMCPSession(
'remote',
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
True,
_app(),
)
error = _http_status_error(403)
session._init_streamable_http_server = AsyncMock(side_effect=error)
session._init_sse_server = AsyncMock()
with pytest.raises(httpx.HTTPStatusError):
await session._init_remote_server()
session._init_streamable_http_server.assert_awaited_once()
session._init_sse_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_read_resource_envelope_truncates_caches_and_records_trace():
session = _connected_session()
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.TextResourceContents(
uri='file:///README.md',
mimeType='text/markdown',
text='abcdef',
)
]
)
query = _query()
first = await session.read_resource_envelope(
'file:///README.md',
max_bytes=4,
source='ui_preview',
query=query,
)
second = await session.read_resource_envelope(
'file:///README.md',
max_bytes=4,
source='agent_tool',
query=query,
)
assert first['contents'][0]['text'] == 'abcd'
assert first['contents'][0]['bytes'] == 6
assert first['truncated'] is True
assert first['cache_hit'] is False
assert second['cache_hit'] is True
assert second['source'] == 'agent_tool'
assert session.session.read_resource.await_count == 1
traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY]
assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool']
assert traces[1]['cache_hit'] is True
assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == {
'ui_preview': 1,
'agent_tool': 1,
}
@pytest.mark.asyncio
async def test_read_resource_envelope_shares_byte_budget_across_text_contents():
session = _connected_session()
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.TextResourceContents(
uri='file:///README.md#first',
mimeType='text/plain',
text='abc',
),
mcp_types.TextResourceContents(
uri='file:///README.md#second',
mimeType='text/plain',
text='def',
),
]
)
envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4)
assert [item['text'] for item in envelope['contents']] == ['abc', 'd']
assert envelope['contents'][0]['truncated'] is False
assert envelope['contents'][1]['truncated'] is True
assert envelope['bytes'] == 6
assert envelope['truncated'] is True
@pytest.mark.asyncio
async def test_read_resource_envelope_omits_binary_by_default():
session = _connected_session(
resources=[
{
'uri': 'file:///image.png',
'name': 'image.png',
'title': '',
'description': '',
'mime_type': 'image/png',
'size': 4,
'icons': [],
'annotations': {},
'_meta': {},
}
]
)
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.BlobResourceContents(
uri='file:///image.png',
mimeType='image/png',
blob=base64.b64encode(b'\x00\x01\x02\x03').decode(),
)
]
)
envelope = await session.read_resource_envelope('file:///image.png')
content = envelope['contents'][0]
assert content['type'] == 'blob'
assert content['blob'] is None
assert content['bytes'] == 4
assert content['binary_omitted'] is True
assert envelope['truncated'] is True
assert envelope['warnings'] == ['Binary resource content omitted from response.']
@pytest.mark.asyncio
async def test_read_resource_envelope_rejects_unlisted_uri():
session = _connected_session()
with pytest.raises(ValueError, match='Resource URI is not available'):
await session.read_resource_envelope('file:///secret.txt')
session.session.read_resource.assert_not_called()
def test_resource_uri_allowed_supports_listed_templates_conservatively():
session = _connected_session(
resources=[],
templates=[
{
'uri_template': 'repo://{owner}/{repo}/file/{path}',
'name': 'repository file',
'title': '',
'description': '',
'mime_type': 'text/plain',
'icons': [],
'annotations': {},
'_meta': {},
}
],
)
assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True
assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False
assert session.resource_uri_allowed('https://example.com/secret') is False
@pytest.mark.asyncio
async def test_mcp_loader_can_hide_synthetic_resource_tools():
loader = MCPLoader(_app())
session = _connected_session()
loader.sessions = {'docs': session}
with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True)
without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False)
assert {tool.name for tool in with_resource_tools} == {
MCP_TOOL_LIST_RESOURCES,
MCP_TOOL_READ_RESOURCE,
}
assert without_resource_tools == []
@pytest.mark.asyncio
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
loader = MCPLoader(_app())
session = _connected_session()
loader.sessions = {'docs': session}
query = SimpleNamespace(
variables={
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_agent_read_enabled': False,
}
)
result = await loader.invoke_tool(
MCP_TOOL_READ_RESOURCE,
{'server_name': 'docs', 'uri': 'file:///README.md'},
query,
)
assert result[0].text == 'Error: MCP resource agent reads are disabled.'
session.session.read_resource.assert_not_called()
@pytest.mark.asyncio
async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources():
loader = MCPLoader(_app())
docs = _connected_session(name='docs', uuid='srv-1')
docs.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.TextResourceContents(
uri='file:///README.md',
mimeType='text/markdown',
text='LangBot MCP resource context',
)
]
)
other = _connected_session(name='other', uuid='srv-2')
other.session.read_resource.return_value = mcp_types.ReadResourceResult(
contents=[
mcp_types.TextResourceContents(
uri='file:///README.md',
mimeType='text/markdown',
text='must not be injected',
)
]
)
loader.sessions = {'docs': docs, 'other': other}
query = SimpleNamespace(
variables={
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_attachments': [
{'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'},
{'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'},
],
}
)
context = await loader.build_resource_context_for_query(query)
assert '<mcp_resource ' in context
assert 'server="docs"' in context
assert 'LangBot MCP resource context' in context
assert 'must not be injected' not in context
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
docs.session.read_resource.assert_awaited_once()
other.session.read_resource.assert_not_called()
@@ -15,13 +15,35 @@ from langbot.pkg.provider.tools.toolmgr import ToolManager
class StubLoader:
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
def __init__(
self,
tools: list[resource_tool.LLMTool] | None = None,
invoke_result=None,
catalog_source: str = 'mcp',
catalog_source_name: str = 'fixture-server',
):
self._tools = tools or []
self._invoke_result = invoke_result
self._catalog_source = catalog_source
self._catalog_source_name = catalog_source_name
async def get_tools(self, *_args, **_kwargs):
return self._tools
async def get_tool_catalog(self, *_args, **_kwargs):
return [
{
'name': tool.name,
'description': tool.description,
'human_desc': tool.human_desc,
'parameters': tool.parameters,
'source': self._catalog_source,
'source_name': self._catalog_source_name,
'source_id': self._catalog_source_name,
}
for tool in self._tools
]
async def has_tool(self, name: str) -> bool:
return any(tool.name == name for tool in self._tools)
@@ -68,6 +90,28 @@ async def test_tool_manager_includes_skill_authoring_tools_when_requested():
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
@pytest.mark.asyncio
async def test_tool_manager_catalog_labels_tool_sources():
manager = ToolManager(SimpleNamespace())
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader(
[make_tool('plugin_tool')],
catalog_source='plugin',
catalog_source_name='fixture-plugin',
)
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
catalog = await manager.get_tool_catalog(include_skill_authoring=True)
assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [
('exec', 'builtin', 'LangBot'),
('activate', 'skill', 'LangBot'),
('plugin_tool', 'plugin', 'fixture-plugin'),
('mcp_tool', 'mcp', 'fixture-server'),
]
@pytest.mark.asyncio
async def test_tool_manager_routes_native_tool_calls():
app = SimpleNamespace()
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from src.langbot.pkg.utils import paths
from langbot.pkg.utils import paths
def test_get_data_root_uses_source_root_in_repo_checkout():
+32 -2
View File
@@ -118,7 +118,12 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
app.tool_mgr.get_all_tools.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=True,
)
@pytest.mark.asyncio
@@ -131,7 +136,32 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
result = await stage.process(_make_query(), 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
app.tool_mgr.get_all_tools.assert_awaited_once_with(
None,
None,
include_skill_authoring=False,
include_mcp_resource_tools=True,
)
@pytest.mark.asyncio
async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace())
stage = preproc_module.PreProcessor(app)
query = _make_query()
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False
result = await stage.process(query, 'PreProcessor')
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
None,
None,
include_skill_authoring=True,
include_mcp_resource_tools=False,
)
@pytest.mark.asyncio
+20 -1
View File
@@ -33,7 +33,7 @@ class TestVectorDBManagerInitialization:
mocks['langbot.pkg.core.app'] = MagicMock()
# Mock all VDB backend implementations
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db']:
for backend in ['chroma', 'qdrant', 'seekdb', 'milvus', 'pgvector_db', 'valkey_search']:
mocks[f'langbot.pkg.vector.vdbs.{backend}'] = MagicMock()
return mocks
@@ -123,6 +123,25 @@ class TestVectorDBManagerInitialization:
mock_seekdb_class.assert_called_once_with(mock_app)
def test_initialize_valkey_search_backend(self):
"""Valkey Search config uses ValkeySearchVectorDatabase backend."""
vdb_config = {'use': 'valkey_search'}
mock_app = self._create_mock_app(vdb_config)
mocks = self._make_vector_import_mocks()
mock_valkey_class = MagicMock()
mocks['langbot.pkg.vector.vdbs.valkey_search'].ValkeySearchVectorDatabase = mock_valkey_class
with isolated_sys_modules(mocks):
from langbot.pkg.vector.mgr import VectorDBManager
mgr = VectorDBManager(mock_app)
import asyncio
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_valkey_class.assert_called_once_with(mock_app)
def test_initialize_milvus_backend_with_uri(self):
"""Milvus config with custom URI."""
vdb_config = {
@@ -0,0 +1,391 @@
"""Unit tests for the Valkey Search VDB backend's pure helpers.
These tests exercise the filter-to-FT mapping, float32 packing, tag/text
escaping, FT.SEARCH reply parsing and the import guard. They run in the fast
CI lane and require NO running Valkey server.
"""
from __future__ import annotations
import asyncio
import struct
from importlib import import_module
from unittest.mock import AsyncMock
import pytest
def get_valkey_module():
"""Lazy import of the valkey_search backend module."""
return import_module('langbot.pkg.vector.vdbs.valkey_search')
def make_backend():
"""Construct a backend instance without running its __init__.
The constructor needs a live ``ap`` + config; for pure-helper tests we
only need a bare instance with the attributes the helpers touch.
"""
mod = get_valkey_module()
backend = object.__new__(mod.ValkeySearchVectorDatabase)
# _ensure_client serializes creation through this lock; set it here since
# __init__ (which normally creates it) is bypassed.
backend._client_lock = asyncio.Lock()
return backend
class TestFloat32Packing:
"""Tests for _pack_vector little-endian float32 packing."""
def test_pack_round_trips(self):
mod = get_valkey_module()
vec = [0.1, -2.5, 3.0, 4.25]
packed = mod.ValkeySearchVectorDatabase._pack_vector(vec)
assert isinstance(packed, bytes)
assert len(packed) == 4 * len(vec)
unpacked = list(struct.unpack(f'<{len(vec)}f', packed))
for original, restored in zip(vec, unpacked):
assert restored == pytest.approx(original, rel=1e-6)
def test_pack_is_little_endian(self):
mod = get_valkey_module()
packed = mod.ValkeySearchVectorDatabase._pack_vector([1.0])
assert packed == struct.pack('<f', 1.0)
class TestTagEscaping:
"""Tests for _escape_tag."""
def test_escapes_special_chars(self):
mod = get_valkey_module()
escaped = mod.ValkeySearchVectorDatabase._escape_tag('a-b c.d')
assert '\\-' in escaped
assert '\\ ' in escaped
assert '\\.' in escaped
def test_plain_value_unchanged(self):
mod = get_valkey_module()
assert mod.ValkeySearchVectorDatabase._escape_tag('abc123') == 'abc123'
class TestFileIdEncoding:
"""Tests for _encode_file_id (FT-unsafe char percent-encoding)."""
def test_uuid_is_noop(self):
mod = get_valkey_module()
fid = '550e8400-e29b-41d4-a716-446655440000'
assert mod.ValkeySearchVectorDatabase._encode_file_id(fid) == fid
def test_encodes_braces_star_and_percent(self):
mod = get_valkey_module()
enc = mod.ValkeySearchVectorDatabase._encode_file_id('a{b}c*d%e')
# '{'=7B '}'=7D '*'=2A '%'=25
assert enc == 'a%7Bb%7Dc%2Ad%25e'
# No raw FT-unsafe char survives.
assert all(ch not in enc for ch in '{}*') or '%' in enc
def test_encoding_is_deterministic_and_collision_safe(self):
mod = get_valkey_module()
enc = mod.ValkeySearchVectorDatabase._encode_file_id
# A literal "%7B" must not collide with an encoded "{".
assert enc('{') != enc('%7B')
assert enc('{') == '%7B'
assert enc('%7B') == '%257B'
def test_filter_encodes_unsafe_chars_in_tag_query(self):
backend = make_backend()
# The emitted TAG query must contain the encoded form, never raw braces.
frag = backend._triples_to_ft({'file_id': 'x}y{z*'})
assert '7D' in frag and '7B' in frag and '2A' in frag
# No raw '*' from the value, and exactly one opening/closing brace (the
# TAG-clause delimiters) — the value's own braces were encoded away.
assert '*' not in frag
assert frag.count('{') == 1 and frag.count('}') == 1
assert frag.startswith('@file_id:{') and frag.endswith('}')
def test_filter_in_operator_encodes_each_value(self):
backend = make_backend()
frag = backend._triples_to_ft({'file_id': {'$in': ['a*b', 'c}d']}})
assert '2A' in frag and '7D' in frag
assert '*' not in frag
class TestFilterToFt:
"""Tests for _triples_to_ft filter mapping (all 8 operators)."""
def test_empty_filter_returns_empty_string(self):
backend = make_backend()
assert backend._triples_to_ft(None) == ''
assert backend._triples_to_ft({}) == ''
def test_eq_tag(self):
backend = make_backend()
assert backend._triples_to_ft({'file_id': 'abc'}) == '@file_id:{abc}'
def test_explicit_eq_tag(self):
backend = make_backend()
assert backend._triples_to_ft({'file_id': {'$eq': 'abc'}}) == '@file_id:{abc}'
def test_ne_tag(self):
backend = make_backend()
assert backend._triples_to_ft({'file_id': {'$ne': 'abc'}}) == '-@file_id:{abc}'
def test_in_tag(self):
backend = make_backend()
assert backend._triples_to_ft({'file_id': {'$in': ['a', 'b']}}) == '@file_id:{a|b}'
def test_nin_tag(self):
backend = make_backend()
assert backend._triples_to_ft({'file_id': {'$nin': ['a', 'b']}}) == '-@file_id:{a|b}'
def test_numeric_range_operators(self):
backend = make_backend()
# file_id is the only indexed field; numeric ops still render via the
# generic range fragment, so use file_id to keep the field supported.
# Values are cast to float (defensive against non-numeric input and a
# future NUMERIC field becoming an injection surface).
assert backend._triples_to_ft({'file_id': {'$gt': 5}}) == '@file_id:[(5.0 +inf]'
assert backend._triples_to_ft({'file_id': {'$gte': 5}}) == '@file_id:[5.0 +inf]'
assert backend._triples_to_ft({'file_id': {'$lt': 5}}) == '@file_id:[-inf (5.0]'
assert backend._triples_to_ft({'file_id': {'$lte': 5}}) == '@file_id:[-inf 5.0]'
def test_numeric_range_rejects_non_numeric(self):
backend = make_backend()
# A non-numeric range value fails closed rather than interpolating raw.
with pytest.raises((ValueError, TypeError)):
backend._triples_to_ft({'file_id': {'$gt': 'not-a-number'}})
def test_unsupported_field_dropped(self):
backend = make_backend()
# Non-indexed fields are dropped (returns empty expression).
assert backend._triples_to_ft({'some_other_field': 'x'}) == ''
def test_multiple_supported_keys_anded(self):
backend = make_backend()
# Two conditions on the same indexed field are joined with a space (AND).
result = backend._triples_to_ft({'file_id': {'$in': ['a', 'b']}})
assert result == '@file_id:{a|b}'
class TestTextEscaping:
"""Tests for _escape_text full-text escaping."""
def test_escapes_ft_special_chars(self):
mod = get_valkey_module()
escaped = mod.ValkeySearchVectorDatabase._escape_text('hello@world|test')
assert '\\@' in escaped
assert '\\|' in escaped
class TestReplyToChroma:
"""Tests for _reply_to_chroma FT.SEARCH reply parsing."""
def test_parses_knn_reply(self):
backend = make_backend()
# glide returns [total, {key: {field: value}}]
reply = [
2,
{
b'kb:col1:id1': {
b'distance': b'0.10',
b'document': b'hello',
b'metadata_json': b'{"file_id": "f1"}',
},
b'kb:col1:id2': {
b'distance': b'0.25',
b'document': b'world',
b'metadata_json': b'{"file_id": "f2"}',
},
},
]
result = backend._reply_to_chroma('idx:col1', reply, has_distance=True)
assert result['ids'][0] == ['id1', 'id2']
assert result['distances'][0] == [pytest.approx(0.10), pytest.approx(0.25)]
assert result['metadatas'][0][0] == {'file_id': 'f1'}
assert result['metadatas'][0][1] == {'file_id': 'f2'}
def test_empty_reply(self):
backend = make_backend()
result = backend._reply_to_chroma('idx:col1', [0, {}], has_distance=True)
assert result == {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
def test_malformed_reply(self):
backend = make_backend()
result = backend._reply_to_chroma('idx:col1', [], has_distance=True)
assert result == {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
def test_text_search_reply_no_distance(self):
backend = make_backend()
reply = [
1,
{
b'kb:col1:id1': {
b'document': b'hello',
b'metadata_json': b'{"file_id": "f1"}',
},
},
]
result = backend._reply_to_chroma('idx:col1', reply, has_distance=False)
assert result['ids'][0] == ['id1']
assert result['distances'][0] == [0.0]
class TestImportGuard:
"""Tests for the ImportError guard when glide is unavailable."""
def test_constructor_raises_when_unavailable(self, monkeypatch):
mod = get_valkey_module()
monkeypatch.setattr(mod, 'VALKEY_SEARCH_AVAILABLE', False)
with pytest.raises(ImportError, match='valkey-glide'):
mod.ValkeySearchVectorDatabase(ap=None)
class TestSupportedSearchTypes:
"""Tests for supported_search_types."""
def test_supports_vector_full_text_hybrid(self):
mod = get_valkey_module()
from langbot.pkg.vector.vdb import SearchType
types = mod.ValkeySearchVectorDatabase.supported_search_types()
assert SearchType.VECTOR in types
assert SearchType.FULL_TEXT in types
assert SearchType.HYBRID in types
class TestDeleteByFilterGuard:
"""Regression tests for the delete_by_filter mass-deletion guard.
A non-empty filter referencing only non-indexed fields must NOT fall back
to match-all and wipe the whole collection: it must skip and return 0.
"""
async def test_unsupported_only_filter_skips_and_returns_zero(self):
backend = make_backend()
# Make the client/index lookups succeed without a real server.
backend._client = AsyncMock()
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensure_client = AsyncMock(return_value=backend._client)
backend._index_exists = AsyncMock(return_value=True)
# _search_keys must never be reached for an unusable filter.
backend._search_keys = AsyncMock(
side_effect=AssertionError('_search_keys must not be called for an unusable filter')
)
# Filter references only a non-indexed field -> maps to no FT conditions.
deleted = await backend.delete_by_filter('col1', {'some_other_field': 'x'})
assert deleted == 0
backend._client.delete.assert_not_called()
async def test_supported_filter_deletes_matching_keys(self):
backend = make_backend()
backend._client = AsyncMock()
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensure_client = AsyncMock(return_value=backend._client)
backend._index_exists = AsyncMock(return_value=True)
backend._search_keys = AsyncMock(return_value=['kb:col1:id1', 'kb:col1:id2'])
deleted = await backend.delete_by_filter('col1', {'file_id': 'f1'})
assert deleted == 2
backend._client.delete.assert_awaited_once_with(['kb:col1:id1', 'kb:col1:id2'])
class TestClose:
"""Tests for the close() teardown."""
async def test_close_resets_client_and_indexes(self):
backend = make_backend()
client = AsyncMock()
backend._client = client
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensured_indexes = {'idx:col1'}
await backend.close()
client.close.assert_awaited_once()
assert backend._client is None
assert backend._ensured_indexes == set()
async def test_close_is_noop_when_no_client(self):
backend = make_backend()
backend._client = None
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensured_indexes = set()
# Should not raise.
await backend.close()
assert backend._client is None
class TestCredentialsBuild:
"""Tests for the auth-credential construction in _ensure_client."""
def _prep_backend(self, mod, monkeypatch, *, username, password):
backend = make_backend()
backend._client = None
backend._host = 'localhost'
backend._port = 6379
backend._db = 0
backend._tls = False
backend._username = username
backend._password = password
backend._request_timeout = 5000
backend._ensured_indexes = set()
warnings: list[str] = []
backend.ap = type(
'Ap',
(),
{
'logger': type(
'L', (), {'info': lambda self, *a, **k: None, 'warning': lambda s, m, *a, **k: warnings.append(m)}
)()
},
)()
created = {}
class _FakeClient:
@staticmethod
async def create(conf):
created['conf'] = conf
return AsyncMock()
cred_calls: list[dict] = []
def _fake_credentials(**kwargs):
cred_calls.append(kwargs)
return ('CRED', kwargs)
# These names are absent when the optional valkey-glide dependency is
# unavailable (for example, on Windows), so allow the test doubles to
# create them on the module.
monkeypatch.setattr(mod, 'GlideClient', _FakeClient, raising=False)
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials, raising=False)
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw, raising=False)
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k), raising=False)
return backend, created, cred_calls, warnings
async def test_username_without_password_fails_closed(self, monkeypatch):
mod = get_valkey_module()
backend, created, cred_calls, warnings = self._prep_backend(mod, monkeypatch, username='acluser', password=None)
# A username without a password must fail closed rather than silently
# connecting unauthenticated to a (potentially shared) Valkey instance.
with pytest.raises(ValueError, match='without a password'):
await backend._ensure_client()
assert cred_calls == [] # ServerCredentials NOT constructed
assert 'conf' not in created # client never created
async def test_password_builds_credentials(self, monkeypatch):
mod = get_valkey_module()
backend, created, cred_calls, warnings = self._prep_backend(
mod, monkeypatch, username='acluser', password='secret'
)
await backend._ensure_client()
assert len(cred_calls) == 1
assert cred_calls[0] == {'password': 'secret', 'username': 'acluser'}
assert created['conf']['credentials'] == ('CRED', {'password': 'secret', 'username': 'acluser'})
Generated
+40 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.10.4"
version = "4.10.5"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2084,6 +2084,7 @@ dependencies = [
{ name = "tiktoken" },
{ name = "urllib3" },
{ name = "uv" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
{ name = "websockets" },
]
@@ -2123,7 +2124,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.4.6" },
{ name = "langbot-plugin", specifier = "==0.4.13" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2172,6 +2173,7 @@ requires-dist = [
{ name = "tiktoken", specifier = ">=0.9.0" },
{ name = "urllib3", specifier = ">=2.7.0" },
{ name = "uv", specifier = ">=0.11.15" },
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
@@ -2187,7 +2189,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.4.6"
version = "0.4.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2208,9 +2210,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b4/6a/5fdb5365ad04aaa61344e92578d73eb1577af35783b80767c7d6c51cb8b9/langbot_plugin-0.4.6.tar.gz", hash = "sha256:838e3cd45ed795ed4c3299c73f141b217adfa05f09937a01694e7158619e4f6e", size = 334171, upload-time = "2026-06-22T15:06:56.565Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/55/7adc2e180a299ed58613e159c64195477e09c05136f949942c6cec5219e8/langbot_plugin-0.4.6-py3-none-any.whl", hash = "sha256:30eb47efc0b703818ac003a5cd67caf720d9749dd503155eb65cce0c28b194a7", size = 217434, upload-time = "2026-06-22T15:06:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" },
]
[[package]]
@@ -5984,6 +5986,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152, upload-time = "2026-05-28T21:41:02.205Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601, upload-time = "2026-05-28T21:41:04.543Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977, upload-time = "2026-05-28T21:41:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446, upload-time = "2026-05-28T21:41:07.833Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646, upload-time = "2026-05-28T21:41:09.451Z" },
{ url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375, upload-time = "2026-05-28T21:41:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469, upload-time = "2026-05-28T21:41:12.733Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744, upload-time = "2026-05-28T21:41:14.634Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972, upload-time = "2026-05-28T21:41:16.063Z" },
{ url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012, upload-time = "2026-05-28T21:41:17.492Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842, upload-time = "2026-05-28T21:41:18.995Z" },
{ url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919, upload-time = "2026-05-28T21:41:20.502Z" },
{ url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906, upload-time = "2026-05-28T21:41:22.135Z" },
{ url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495, upload-time = "2026-05-28T21:41:23.783Z" },
{ url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720, upload-time = "2026-05-28T21:41:25.361Z" },
{ url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318, upload-time = "2026-05-28T21:41:26.996Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155, upload-time = "2026-05-28T21:41:42.399Z" },
{ url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571, upload-time = "2026-05-28T21:41:44.078Z" },
{ url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021, upload-time = "2026-05-28T21:41:45.837Z" },
{ url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093, upload-time = "2026-05-28T21:41:47.617Z" },
]
[[package]]
name = "virtualenv"
version = "20.36.1"
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+41 -39
View File
@@ -191,45 +191,47 @@ export default function BotDetailContent({ id }: { id: string }) {
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
{activeTab === 'sessions' && (
<button
type="button"
className="inline-flex items-center justify-center ml-0.5"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3 text-muted-foreground hover:text-foreground transition-colors',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
<TabsList>
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
</TabsList>
{activeTab === 'sessions' && (
<button
type="button"
aria-label={t('bots.sessionMonitor.refresh')}
title={t('bots.sessionMonitor.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isRefreshingSessions}
onClick={() => {
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3.5',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</div>
{/* Tab: Configuration */}
<TabsContent
@@ -3,6 +3,7 @@ import React, {
useEffect,
useRef,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from 'react';
@@ -15,11 +16,14 @@ import {
Bot,
Copy,
Check,
ChevronDown,
ChevronRight,
Workflow,
ThumbsUp,
ThumbsDown,
ShieldCheck,
ShieldOff,
Wrench,
} from 'lucide-react';
import { toast } from 'sonner';
import BotAdminsDialog, {
@@ -76,6 +80,35 @@ interface SessionFeedback {
stream_id?: string | null;
}
interface SessionToolCall {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
message_id?: string | null;
arguments?: string | null;
result?: string | null;
error_message?: string | null;
}
type SessionTimelineItem =
| {
id: string;
type: 'message';
timestamp: number;
order: number;
message: SessionMessage;
}
| {
id: string;
type: 'tool';
timestamp: number;
order: number;
toolCall: SessionToolCall;
};
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
@@ -100,6 +133,10 @@ const BotSessionMonitor = forwardRef<
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
>({});
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
@@ -189,6 +226,7 @@ const BotSessionMonitor = forwardRef<
const loadMessages = useCallback(
async (sessionId: string) => {
setLoadingMessages(true);
setExpandedToolCallIds({});
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort(
@@ -197,6 +235,18 @@ const BotSessionMonitor = forwardRef<
);
setMessages(sorted);
try {
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
);
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
// Collect user message IDs for feedback matching
const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
@@ -240,11 +290,14 @@ const BotSessionMonitor = forwardRef<
loadMessages(selectedSessionId);
} else {
setMessages([]);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
}, [selectedSessionId, loadMessages]);
useEffect(() => {
if (messages.length === 0) return;
if (messages.length === 0 && toolCalls.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
@@ -256,7 +309,7 @@ const BotSessionMonitor = forwardRef<
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
}, [messages]);
}, [messages, toolCalls]);
const parseMessageChain = (content: string): MessageChainComponent[] => {
try {
@@ -431,6 +484,71 @@ const BotSessionMonitor = forwardRef<
return `${diffDays}d`;
};
const formatDuration = (durationMs: number): string => {
if (!durationMs) return '0ms';
if (durationMs < 1000) return `${durationMs}ms`;
return `${(durationMs / 1000).toFixed(2)}s`;
};
const truncateToolDetail = (value?: string | null): string => {
if (!value) return '';
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
};
const toggleToolCallDetails = (toolCallId: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallId]: !previous[toolCallId],
}));
};
const feedbackByMessageId = useMemo(() => {
const map: Record<string, SessionFeedback> = {};
for (let index = 0; index < messages.length; index++) {
const msg = messages[index];
if (isUserMessage(msg)) continue;
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
const previousMessage = messages[previousIndex];
if (isUserMessage(previousMessage)) {
const feedback = feedbackMap[previousMessage.id];
if (feedback) {
map[msg.id] = feedback;
}
break;
}
}
}
return map;
}, [feedbackMap, messages]);
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
const messageItems: SessionTimelineItem[] = messages.map(
(message, index) => ({
id: `message-${message.id}`,
type: 'message',
timestamp: parseTimestamp(message.timestamp).getTime(),
order: index * 2,
message,
}),
);
const toolItems: SessionTimelineItem[] = toolCalls.map(
(toolCall, index) => ({
id: `tool-${toolCall.id}`,
type: 'tool',
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
order: index * 2 + 1,
toolCall,
}),
);
return [...messageItems, ...toolItems].sort(
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
);
}, [messages, toolCalls]);
const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId,
);
@@ -612,29 +730,162 @@ const BotSessionMonitor = forwardRef<
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messages.length === 0 ? (
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg, msgIndex) => {
timelineItems.map((item) => {
if (item.type === 'tool') {
const call = item.toolCall;
const hasToolDetails = Boolean(
call.arguments || call.result || call.error_message,
);
const expandedToolCall = Boolean(
expandedToolCallIds[call.id],
);
const detailsId = `tool-call-details-${call.id}`;
return (
<div key={item.id} className="flex justify-start">
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(call.id)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
))}
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
{call.tool_name}
</span>
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{call.tool_source}
</span>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
call.status === 'success'
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-2 space-y-1.5"
>
{(call.arguments || call.result) && (
<div className="space-y-1.5">
{call.arguments && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t(
'monitoring.toolCalls.arguments',
{
defaultValue: '参数',
},
)}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.result)}
</pre>
</div>
)}
</div>
)}
{call.error_message && (
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{call.error_message}
</div>
)}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}
</span>
<span className="tabular-nums">
{formatTime(call.timestamp)}
</span>
{hasToolDetails && (
<>
<span>·</span>
<span>
{expandedToolCall
? t(
'monitoring.toolCalls.hideDetails',
{
defaultValue: '隐藏详情',
},
)
: t(
'monitoring.toolCalls.showDetails',
{
defaultValue: '查看详情',
},
)}
</span>
</>
)}
</div>
</div>
</div>
);
}
const msg = item.message;
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
}
}
const msgFeedback = feedbackByMessageId[msg.id];
return (
<div
key={msg.id}
key={item.id}
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start',
@@ -1,4 +1,5 @@
import {
DynamicFormItemType,
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
@@ -57,6 +58,95 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'required' | 'type'
>;
function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] {
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
return [
item,
{
name: 'enable-all-tools',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
return [
item,
{
name: 'mcp-resources',
type: DynamicFormItemType.UNKNOWN,
required: false,
default: [],
},
{
name: 'mcp-resource-agent-read-enabled',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
return [item];
}
function getValueSchema(spec: DynamicFormValueSpec) {
if (spec.name === 'mcp-resources') {
return z.array(z.any());
}
switch (spec.type) {
case DynamicFormItemType.INT:
return z.number();
case DynamicFormItemType.FLOAT:
return z.number();
case DynamicFormItemType.BOOLEAN:
return z.boolean();
case DynamicFormItemType.STRING:
return z.string();
case DynamicFormItemType.STRING_ARRAY:
return z.array(z.string());
case DynamicFormItemType.SELECT:
return z.string();
case DynamicFormItemType.LLM_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
case DynamicFormItemType.RESOURCES_SELECTOR:
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
case DynamicFormItemType.TOOLS_SELECTOR:
return z.array(z.string());
case DynamicFormItemType.BOT_SELECTOR:
return z.string();
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR:
return z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
default:
return z.string();
}
}
/**
* Display-only component for embed code fields with copy animation.
*/
@@ -322,9 +412,16 @@ export default function DynamicFormComponent({
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: IDynamicFormItemSchema,
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
@@ -368,68 +465,16 @@ export default function DynamicFormComponent({
[itemConfigList],
);
const editableValueSpecs = useMemo(
() => editableItems.flatMap(getValueSpecs),
[editableItems],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
editableItems.reduce(
editableValueSpecs.reduce(
(acc, item) => {
let fieldSchema;
switch (item.type) {
case 'integer':
fieldSchema = z.number();
break;
case 'float':
fieldSchema = z.number();
break;
case 'boolean':
fieldSchema = z.boolean();
break;
case 'string':
fieldSchema = z.string();
break;
case 'array[string]':
fieldSchema = z.array(z.string());
break;
case 'select':
fieldSchema = z.string();
break;
case 'llm-model-selector':
fieldSchema = z.string();
break;
case 'embedding-model-selector':
fieldSchema = z.string();
break;
case 'rerank-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
break;
case 'prompt-editor':
fieldSchema = z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
break;
default:
fieldSchema = z.string();
}
let fieldSchema = getValueSchema(item);
if (
item.required &&
@@ -454,7 +499,7 @@ export default function DynamicFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: editableItems.reduce((acc, item) => {
defaultValues: editableValueSpecs.reduce((acc, item) => {
// 优先使用 initialValues,如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
@@ -493,7 +538,7 @@ export default function DynamicFormComponent({
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = editableItems.reduce(
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
@@ -508,10 +553,16 @@ export default function DynamicFormComponent({
previousInitialValues.current = initialValues;
}
}, [initialValues, form, editableItems]);
}, [initialValues, form, editableValueSpecs]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
const setFormValue = (name: string, value: unknown) => {
form.setValue(name as keyof FormValues, value as never, {
shouldDirty: true,
shouldValidate: true,
});
};
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render.
@@ -524,7 +575,7 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = editableItems.reduce(
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -544,7 +595,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = editableItems.reduce(
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -555,7 +606,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = finalValues as Record<string, object>;
});
return () => subscription.unsubscribe();
}, [form, editableItems]);
}, [form, editableValueSpecs]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
@@ -786,6 +837,41 @@ export default function DynamicFormComponent({
);
}
if (
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
) {
return (
<FormField
key={config.id}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem className="min-w-0">
<FormControl>
<div
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
@@ -816,7 +902,10 @@ export default function DynamicFormComponent({
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</FormControl>
</div>
@@ -853,7 +942,10 @@ export default function DynamicFormComponent({
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>

Some files were not shown because too many files have changed in this diff Show More