- Updated the assertion in `test_dingtalk_completed_input_lines_include_text_and_select_values` to remove unnecessary markdown formatting.
- Added new tests to verify that `_dingtalk_clean_form_content` maintains the order of prompts and completed values in various scenarios.
- Introduced `test_dingtalk_card_markdown_preserves_internal_line_breaks` to ensure internal line breaks are correctly converted to HTML line breaks.
feat(qqofficial): update fallback text handling for non-streaming scenarios
feat(difysvapi): enhance form content processing for interactive fields and actions
test: add unit tests for Lark and QQ Official adapter functionalities
- Added support for select fields in Telegram adapter, including option extraction and callback handling.
- Implemented form action processing for Telegram callbacks, improving user interaction feedback.
- Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter.
- Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes.
- Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions.
- Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines.
- Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines.
- Add tests for WeComBot template card functionalities, including event extraction and payload building for human input.
- Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction.
* 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>
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>
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>
* 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>
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>
_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>
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>
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>
* 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>
* 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>