* feat(mcp): surface OAuth-required server tests
* fix(mcp): show connection failure details in status cards
---------
Co-authored-by: RockChinQ <rockchinq@gmail.com>
The Ollama requester declared litellm_provider: ollama, which routes
every request through litellm's legacy /api/generate-based
OllamaConfig. That config's get_supported_openai_params() does not
include "tools"/"tool_choice" at all, so an Ollama-hosted model in a
local-agent pipeline could never receive a structured tool definition
or return a structured tool_calls response - it could only try to
express a tool call as free text (typically inside its own <think>
reasoning), which LangBot then has no way to execute.
litellm's "ollama_chat" provider targets Ollama's modern /api/chat
endpoint instead, which correctly forwards tools/tool_choice and
correctly surfaces the model's native message.tool_calls field.
Verified against a real local Ollama 0.33.2 instance with the exact
system prompt, RAG-augmented user message, and tool set a live
pipeline sends.
Two follow-on fixes needed because the Ollama requester definition is
shared by LLM and text-embedding models:
- get_reasoning_capabilities: match family in ('ollama', 'ollama_chat')
so the reasoning-level UI still works for this provider.
- scan_models: retry {base_url}/v1/models on a 404 from {base_url}/models,
since Ollama's base_url is a bare host (must not include /v1 - that
would break OllamaChatConfig.get_complete_url, which appends /api/chat
to it directly), unlike most other OpenAI-compatible providers whose
base_url already ends in /v1.
- invoke_embedding: litellm's embedding routing has no "ollama_chat"
case, only "ollama". Build the embedding model name with an explicit
custom_llm_provider="ollama" override when the requester is configured
for ollama_chat, so embedding models (e.g. bge-m3) keep working.
Co-authored-by: zx90316 <zx90316@users.noreply.github.com>
execute_func_call returns list[ContentElement] for MCP tools, but the
runner assigned that list directly to the tool-message content. The
OpenAI chat-completions spec requires tool-message content to be a
string, so OpenAI-compatible endpoints return HTTP 500 when the raw
list is sent.
Serialize the list to a string before building the tool message, using
ContentElement.__str__ which returns the text payload for text elements
and a human-readable placeholder for images and files. Fixes#2457.
SessionManager clears image_base64 on past turns to save memory, and
exclude_none serialization drops the hollowed field entirely, so a
replayed history part can arrive as {'type': 'image_base64'} with no
payload. The converter accessed the missing key unconditionally and
raised KeyError on every turn after an image was sent.
Prefer the base64 payload when present, fall back to an image_url that
survived on the same element, and drop hollow parts otherwise (same
strategy as the existing file-part handling). Fixes#2469.
Previously, MCP tool calls via call_tool() had no timeout, so a hung MCP
server would block the entire session indefinitely (exacerbated by
concurrency.session=1). This wraps the call in asyncio.timeout(30) and
raises an Exception on expiry, letting the LLM recover gracefully.
Closes#2339
When the LLM (e.g. MiniMax-M3) returns multiple rounds after tool calls, the _StreamAccumulator was initialized with initial_content=first_content, causing every subsequent round to repeat the entire first message. Remove the re-seeding so each round starts with a clean accumulator.
Add regression test verifying multi-round tool call content is not duplicated.
* fix(provider): strip think tags for MiniMax-M3 and other OpenAI-compatible models
MiniMax-M3 (and other OpenAI-compatible providers) emit chain-of-thought
reasoning directly in the content field wrapped in tags, instead
of using a separate reasoning_content field or the legacy CRETIRE_REASONING
markers. The existing remove_think logic only handled CRETIRE_* tags, so
think blocks leaked into user-visible output even when remove_think was enabled.
- Add _ThinkStripState: a stateful filter that correctly handles tags
split across streaming chunk boundaries.
- Add _strip_think classmethod with regex patterns for both and
CRETIRE_* tags.
- Wire think_state into invoke_llm_stream so deltas are filtered before
reaching the accumulator.
- Add remove_think safety net in _StreamAccumulator so the final message
from tool-call rounds also gets stripped.
- Fix remove_think resolution to use defensive nested .get() so
pipelines missing output.misc don't raise AttributeError.
* fix(litellmchat): add missing _CLOSE_TAG class attribute on _ThinkStripState
* fix(provider): handle think stripping across LiteLLM paths
---------
Co-authored-by: WangCham <651122857@qq.com>
* feat: Implement workflow form handling for paused workflows
- Added module-level storage for pending forms to manage state across sessions.
- Introduced functions to set, get, and clear pending forms with expiration handling.
- Enhanced DifyServiceAPIRunner to support resuming paused workflows via form actions.
- Implemented logic to yield human input requests and display appropriate messages.
- Updated workflow submission methods to handle paused states and resume actions.
- Ensured proper merging of pending form actions with user inputs for seamless interaction.
* feat: Add '_routed_by_rule' variable to form action in Lark and Telegram adapters
* feat: Enhance Lark and Telegram adapters with new form handling for paused workflows
* feat: Enhance TelegramAdapter to handle form action buttons and message threading
* feat: Improve TelegramAdapter message handling with enhanced error management and draft message support
* feat: Add the function for formatting human input text to support adapters without rich UI.
* feat(dingtalk): implement human input card support and card action handling
- Add a new module `card_callback.py` to handle card action button clicks from DingTalk.
- Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters.
- Update `DingTalkAdapter` to manage card state and handle form input through a single card template.
- Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input.
- Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons.
* feat(dingtalk): enhance human input card functionality with streaming support and active turn management
- Updated the DingTalk card template to enable streaming mode and multi-update configuration.
- Removed the obsolete delete_card method from DingTalkClient to streamline card management.
- Enhanced DingTalkAdapter to manage active turn cards and accumulated streaming text, ensuring a seamless user experience during human input prompts.
- Modified the create_message_card method to utilize existing active cards for resumed workflows, preventing duplication.
- Improved the _paint_form_on_card method to update existing cards with human input prompts and buttons dynamically.
- Updated the dingtalk_human_input_card.json template to reflect the new streaming capabilities and configuration options.
* feat(wecom): implement Dify human input pause handling with button interaction support
* feat(qqofficial): implement Dify human input button interaction handling and markdown keyboard support
* feat(qqofficial): implement one-click QR binding and enhance localization support
* feat(discord): implement Discord form view with button interactions for Dify actions
* fix(telegram): correct group chat type check and handle oversized callback data for Telegram actions
fix(difysvapi): ensure safe access to remove-think configuration in pipeline settings
* feat(dify): add support for chatflow app type and enhance human input handling
* feat(telegram): add action title feedback for user selections in Telegram messages
* feat(lark): enhance LarkAdapter to store form content for resume notices
* feat(dingtalk): update display formatting for card content with HTML line breaks
* feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons
- Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons.
- Enhanced card rendering to include feedback buttons when appropriate.
- Registered feedback listeners to handle feedback events and update card states accordingly.
- Updated the card template to support dynamic button rendering for feedback actions.
- Improved error handling and logging for feedback actions and card updates.
* fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction
* feat(wecom): add optional source block to interactive template cards for enhanced branding
* feat(wecom): add functions for template card action extraction and update, enhance button interaction handling
* feat(qqofficial): synchronize passive-reply counter with inbound message sequence
* feat(qqofficial): add method to identify invisible form placeholder chunks in messages
* feat(dingtalk): add download link for human input card template and enhance dynamic form configuration
* feat(telegram): enhance message handling with group stream deletion and form placeholder detection
* Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners
- 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: Enhance Telegram and QQ Official adapters with select field handling and form action processing
- 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.
* feat(lark): add functions for current input definitions and visible form content handling
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
* Add tests for DingTalk adapter content processing and markdown formatting
- 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: Refactor input handling and feedback messages across multiple adapters
* feat: Update the human-computer interaction template cards, and optimize the prompt information and content display.
* feat: Refactor pending form handling to isolate by bot and pipeline
* feat: Enhance error handling and caching for Dify and WeCom interactions
* feat: Enhance select input handling and validation in Dify API runner and Telegram adapter
* feat: Add missing completed input lines handling in DingTalk adapter
* feat: Add pipeline_uuid handling across multiple adapters and update related tests
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>
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>
CI follow-up to the local/remote MCP work:
- Apply ruff format to provider/tools/loaders/mcp.py and the 0006
normalize-remote-mode migration (Lint job failed on formatting).
- test_migrations.py hardcoded the head revision as 0005_*, which broke
once 0006 landed. Resolve the actual head from the Alembic
ScriptDirectory so future migrations don't require editing the test.
Replace the three-way transport choice (stdio / sse / httpstream) for
connecting LangBot to external MCP servers with two modes: local (stdio)
and remote. Remote servers only require a URL; the runtime auto-detects
the transport (tries Streamable HTTP, falls back to SSE).
- provider/tools/loaders/mcp.py: add _init_remote_server() with
Streamable-HTTP-then-SSE probing; dispatch 'remote' lifecycle, keep
legacy sse/http branches for back-compat
- plugin/connector.py: normalize legacy http/sse marketplace modes to
'remote' on Space install, preserving connection params
- entity/persistence/mcp.py: document mode as stdio, remote (legacy: sse, http)
- alembic 0006: idempotent data migration mapping existing sse/http rows
to remote (downgrade maps back to http)
- api/http/service/mcp.py: stash runtime_info (status + tool list) into
test task metadata before tearing down the temp session
- web: collapse mode dropdown to local/remote, remote renders URL+timeout
only, edit auto-maps legacy sse/http to remote; show tools after test in
create mode from task metadata; remove dead plugins/mcp-server/ tree
- i18n: local/remote labels + mode/url hints across 8 locales
Update _normalize_stream_tool_calls to preserve provider_specific_fields
(including thought_signature) from streaming tool call chunks. Also preserve
provider_specific_fields from delta in invoke_llm_stream.
This ensures Gemini's thought_signature is round-tripped correctly:
1. LiteLLM extracts thought_signature from Gemini response
2. It's preserved in Message/ToolCall entities (via SDK changes)
3. _convert_messages includes it in the next request
Also add unit tests for provider_specific_fields round-tripping.
Fixes: langbot-app/LangBot#1899
Adding a Kimi/Moonshot provider failed model scanning out of the box for
CN-region API keys: the single preset defaulted its base URL to the
global endpoint `https://api.moonshot.ai/v1`, but CN-issued keys are only
valid against `https://api.moonshot.cn/v1`, so scanning returned
`401 Invalid Authentication`. Flipping the default would just move the
breakage to international keys, since the base_url field is plain
free-text and either region is equally common.
Instead, offer two clearly labelled presets, mirroring how the Lark
adapter exposes feishu.cn vs larksuite.com:
- `moonshot-chat-completions` -> "Moonshot / Kimi (Global · api.moonshot.ai)"
- `moonshot-cn-chat-completions` -> "Moonshot / Kimi (China · api.moonshot.cn)"
The existing component name is kept unchanged so provider rows already in
the DB keep resolving; only its display label is clarified. Both presets
keep base_url as a free-text field, so users behind a proxy / one-api
gateway can still enter a custom endpoint. Both carry the same `kimi`
search aliases so either shows up when searching.
Fixes#2232
Ollama's OpenAI-compatible streaming endpoint emits a tool-call delta
carrying an `index` and a `function` payload but never an OpenAI-style
`id`. `_normalize_stream_tool_calls` dropped any tool call without an
`id`, so a tool-only turn yielded neither content nor a tool call: the
stream "completed" with 0 chars, the tool never ran, and the chat
appeared stuck. Models on standard OpenAI APIs (e.g. SiliconFlow) were
unaffected because they always send a `call_...` id.
Synthesize a stable per-index id (`call_<index>`) when the provider
omits one but a function name is present. Providers that do send ids
keep theirs, and parallel id-less calls keep distinct ids.
Adds regression tests for the single and multi id-less tool-call cases.
Fixes#2261
Outbound attachment collection (pipeline wrapper) runs on every turn
regardless of inbound files, but the agent was only told the per-query
outbox path inside the inbound-attachment note in LocalAgentRunner. So on
pure-generation turns (e.g. "generate a QR code"/chart/mermaid where the
user sent no file), the agent never learned the outbox path or the
query_id, wrote the generated file nowhere deliverable, and it was
silently dropped.
Move the outbox instruction into BoxService.get_system_guidance(query_id),
which is injected as a system message on every turn the exec tool is
available. The inbound note keeps its own (now redundant but harmless)
outbox line. Add unit tests asserting the outbox path is present with a
query_id and absent without one.
* feat(box): bidirectional attachment transfer for sandbox
Materialize inbound attachments into the sandbox workspace so agents can
process user-sent files, and collect agent-produced files from the outbox
to attach them back to the reply.
- box(service): add materialize_inbound_attachments / collect_outbound
attachments. Prefer direct host-filesystem read/write on the bind-mounted
workspace (no size limit), falling back to chunked exec only for
non-shared backends (e2b/remote). Clear per-query inbox/outbox dirs at
turn start to avoid query_id-reuse collisions.
- provider(localagent): inject inbound attachment descriptors into the
sandbox and append a system note telling the agent the inbox/outbox paths.
- pipeline(wrapper): collect outbox files on the final stream chunk and
append them as attachment components to the response chain.
- web(debug-dialog): render File components with a download link when
base64/url is present; add base64/path fields to the File entity.
- tests: cover inbound/outbound, large-file transfer without truncation,
and stale-dir clearing (86 passing).
* feat(box): support voice/file attachment round-trip end-to-end
Extends the bidirectional attachment transfer to audio and arbitrary files
through the real webchat UI, and fixes the model-payload errors that
non-image attachments triggered.
- platform(websocket_adapter): resolve Voice/File component storage keys to
base64 (previously only Image), so audio/documents reach the sandbox inbox.
- web(debug-dialog): accept audio/* and any file in the uploader (was
image-only), classify by mimetype, upload Voice/File via the documents
endpoint, and render non-image staged attachments as a chip.
- provider(litellmchat): drop non-image file parts (file_base64 / file_url)
when building the OpenAI/LiteLLM payload. These come from Voice/File
attachments — including ones replayed from conversation history — and the
agent reads their bytes from the sandbox, not the model. Without this the
provider rejects the request: 'invalid content type=file_base64'.
- provider(localagent): also strip those parts from the current user message
alongside the sandbox-path note (model-facing clarity; the requester is the
real safety net for history).
- tests: cover the requester strip/keep behavior (file dropped, image kept and
reshaped to image_url, mixed history, plain-string content).
* test(box): cover inbound/outbound attachment helpers; fix ruff format
- ruff format localagent.py (CI ruff format --check was failing)
- add unit tests for ResponseWrapper outbound-attachment helpers (wrapper.py 78%->98%)
- add unit tests for LocalAgentRunner._inject_inbound_attachments
- add unit tests for WebSocketAdapter._process_image_components (0%->covered)
Lifts PR patch coverage from 68.97% to ~88% (>75% target).