_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>
The Telegram (EBA) manifest was missing spec.categories, so it fell
into the uncategorized/protocol bucket. Restore popular + global to
match the legacy telegram adapter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 12 old adapters that now have an EBA replacement are tagged
`spec.legacy: true` in their source manifests. Principle: don't delete,
de-emphasize.
- sources/*.yaml (aiocqhttp, dingtalk, discord, kook, lark,
officialaccount, qqofficial, slack, telegram, wecom, wecombot,
wecomcs): add spec.legacy: true
- Adapter / IChooseAdapterEntity types: add optional legacy flag
- BotForm adapter Select: split legacy adapters into a collapsed,
grayscale group at the bottom with an explanatory hint; auto-expand
when the bot already uses a legacy adapter
- Wizard platform picker: same collapsed legacy section
- i18n: legacyAdapters / legacyAdaptersHint (zh-Hans, en-US)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /change-password and /bind-space endpoints already refuse when
system.allow_modify_login_info is false, but /set-password did not,
leaving a path to alter login credentials on locked-down deployments
(e.g. public demo instances). Apply the same guard.
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
- Replace legacy pipeline binding card + RoutingRulesEditor with unified
EventBindingsEditor; remove use_pipeline_uuid/pipeline_routing_rules
from bot form schema and API update handler
- Add _augment_event_data() to botmgr for filter virtual fields
(message_text, message_element_types, chat_type)
- Add alembic migration 0009: migrate use_pipeline_uuid and
pipeline_routing_rules into event_bindings on first run
- Fix command.tsx: data-[disabled] -> data-[disabled=true] so cmdk 1.x
items (data-disabled=false) are not pointer-events:none
- EventBindingsEditor: onSelect on CommandItems, filter conditions panel,
disabled bindings section, dnd reorder
- i18n: add filter/condition keys for zh-Hans and en-US
- Update tests to match new bot service behavior
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the PluginToolLoader.get_tool() override that returned a raw
ComponentManifest, so every loader's get_tool() now returns a uniform
resource_tool.LLMTool (PluginToolLoader.get_tools() already did this
conversion). This removes the only source of tool-shape heterogeneity.
- ToolManager.get_tool_schema(): drop the ComponentManifest-vs-LLMTool branch
- ToolManager.get_tool_detail(): new host-level shape {name, description,
human_desc, parameters}
- handler.py GET_TOOL_DETAIL: call tool_mgr.get_tool_detail(); delete the
handler-local _build_tool_detail + _i18n_to_dict/_i18n_to_text adapters and
the litellm TODO
- ToolLookupResult is now just LLMTool
The dropped label/spec fields were not consumed by any runner (local-agent
build_llm_tool and external harnesses use only name/description/parameters).
* docs(platform): add HTTP Bot adapter design (RFC)
Standalone server-to-server HTTP adapter for driving a pipeline from external
systems (LangBot Space ticketing et al). Inbound via the existing unified
webhook route; outbound via signed callback POSTs. Preserves pipeline-native
N->1 aggregation and 1->M multi-reply without a long-lived WebSocket.
No core changes required (router/aggregator/pipeline untouched).
* feat(platform): add standalone HTTP Bot adapter
A first-class, vendor-neutral message-platform adapter (http_bot) for
server-to-server integrations (LangBot Space ticketing et al). Drives a
pipeline over plain HTTP with no long-lived connection:
- Inbound: signed POST to the existing unified webhook route /bots/<uuid>,
carrying a caller-defined session_id mapped to the LangBot launcher id via
get_launcher_id -> per-session isolation. Preserves pipeline-native N->1
aggregation for free.
- Outbound: each reply_message / reply_message_chunk becomes one signed
callback POST to the config-only callback_url, delivered in per-session
sequence order with retry/backoff -> 1->M multi-reply.
- Sub-paths: /reset (drop a session) and /sync (block for the collapsed reply).
- Auth: symmetric HMAC-SHA256 both directions (timestamp + replay window),
no JWT/Turnstile, no socket.
Decisions: callback URL is config-only (SSRF closed); reset + sync shipped;
Python + TS reference clients shipped (signing verified byte-identical 3-way).
No core changes: the unified webhook router, aggregator, query pool and
pipeline are untouched. Adapter is auto-discovered from platform/sources/.
Adds:
src/langbot/pkg/platform/sources/http_bot.{py,yaml,svg}
src/langbot/pkg/platform/sources/http_bot_signing.py
docs/platforms/http-bot.md, docs/http-bot-openapi.json
examples/http-bot/{client.py,client.ts,README.md}
Updates docs/HTTP_BOT_ADAPTER_DESIGN.md (status: implemented).
* docs(examples): add interactive HTTP Bot playground (browser debug console)
A single-file aiohttp web app (examples/http-bot/playground.py) that lets you
chat with a RUNNING http_bot bot from the browser and watch the protocol live:
signed inbound POST -> 202 ack -> 1->M signed callbacks streamed back via SSE,
with a debug panel showing the signature, HTTP status, and per-callback
sequence/verification. Light LangBot-styled UI.
On startup it reads the API key + http_bot bot from data/langbot.db and points
the bot's callback_url + secrets back at itself via the LangBot API (live
reload, no restart). README updated with a playground section.
* docs(examples): add Chinese README for http-bot reference clients
* style(platform): use </> code icon for http_bot adapter logo
* docs(examples): point http-bot guide links to docs.langbot.app
* style(platform): make http_bot icon a transparent monochrome </> so WebUI tints it like other adapters
* Revert to colorful </> badge for http_bot icon (WebUI renders it as-is)
Extract the AgentRunner Protocol v1 host-side surface from the giant
RuntimeConnectionHandler.__init__ into sibling modules using a registration-
function pattern (behavior-preserving; @h.action == @self.action):
- agent_run_support.py: shared constants + authorization/scope/projection helpers
- agent_pull_actions.py: register(h) for history/event pull APIs
- agent_runner_actions.py: register(h) for run/runtime/stats/claim lifecycle
- agent_state_actions.py: register(h) for steering/state APIs
__init__ now calls the three register(self) functions. handler.py keeps the
pre-existing plugin/llm/vector/knowledge handlers, get_prompt/call_tool/
get_tool_detail (coupled to retained helpers), shared helpers, and outbound
methods; it re-imports _validate_agent_run_session so external imports keep
working. handler.py: 4066 -> 1871 lines.
test_state_api_auth.py: repoint get_session_registry patch targets to
agent_run_support (the lookup moved modules). 385 agent unit tests pass; ruff clean.
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
Expose skill tools (activate/register_skill/native exec) like native tools
instead of gating them behind the skill_authoring capability:
- toolmgr.get_all_tools drops include_skill_authoring; SkillToolLoader
self-gates on sandbox + skill_mgr
- preproc drops the include_skill_authoring branch; pipeline-bound skills
and the skills resource gate on skill_mgr presence
Persist activated skills into host.activated_skills conversation state so
they survive across runs (host writes at activate; last-write-wins); drop
the dead restore_activated_skills helper.
Prefill ToolResource.parameters host-side (tool_mgr.get_tool_schema) so
runners build LLM tools without per-tool get_tool_detail round-trips.
Align agent-runner-pluginization design docs to the all-tool model.
* feat(api): support global API key from config.yaml (api.global_api_key)
Accept a config-defined global API key anywhere a web-UI key is accepted
(X-API-Key / Bearer), with no login session and no DB record. Useful for
automated deployments and AI agents (HTTP API + MCP). Defaults to empty
(disabled); does not require the lbk_ prefix.
- templates/config.yaml: add api.global_api_key with security notes
- service/apikey.py: verify_api_key checks global key first (constant-time)
- docs/API_KEY_AUTH.md: document the global key + security guidance
- tests: cover global-key match, prefix-free, fallback-to-db, disabled
* feat(mcp): expose LangBot management as an MCP server at /mcp
Add an MCP (Model Context Protocol) server so external AI agents can manage a
LangBot instance. Reuses the same API-key auth as the HTTP API (including the
config.yaml global API key).
- pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated
tools across system/bots/pipelines/models/knowledge/mcp-servers/skills
- pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp
requests with an API key, runs the streamable-HTTP session manager lifespan
- controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task)
- web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and
client config; i18n for 8 locales
- tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools)
Tool surface is intentionally curated (not all ~25 route groups) to keep the
agent surface small, safe, and maintainable. Extend deliberately.
* feat(skills): add in-repo skills/ as the single source of truth
Migrate the agent skills + QA/e2e test harness from the (now archived)
langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills.
Migrated:
- langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup,
langbot-skills-maintenance, langbot-eba-adapter-dev
- the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/)
New:
- langbot-dev core backend + web development
- langbot-deploy Docker/K8s deployment + config.yaml + global API key
- langbot-mcp-ops operating the LangBot MCP server (/mcp)
- langbot-space-ops operating the Space marketplace MCP server
- src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json +
bin/lbs) so the CLI works when nested inside the LangBot repo
- README.md: unified skill catalog; skills.index.json regenerated
Parity with source verified: bin/lbs validate + node test suite match the
source repo (only the uncommitted .lbpkg build-artifact fixture differs).
* docs(agents): document agent-facing surfaces + API/MCP/skills sync rule
* docs(readme): add 'Built for AI Agents' section across all locales
Highlight MCP server, in-repo skills (single source of truth), AGENTS.md
sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace.
* style(mcp): fix ruff format + prettier lint in MCP server and API panel
* style(web): prettier format MCP i18n locale entries
* docs(skills): note MCP instance control in dev/testing skills
All development-guidance skills now point to the LangBot instance MCP
server (/mcp) and the Space marketplace MCP server, reusing API keys.
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