From 99d9c227f9cabb773daa4c1263a55fce8011e71a Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:36:32 +0800 Subject: [PATCH] feat(agent-runner): enforce 4.x host-owned execution --- AGENTS.md | 2 +- ARCHITECTURE.md | 21 +- .../EVENT_BASED_AGENT.md | 15 +- .../agent-runner-pluginization/PROTOCOL_V1.md | 5 + docs/agent-runner-pluginization/STATUS.md | 7 +- docs/event-based-agents/00-overview.md | 38 +- docs/event-based-agents/01-event-system.md | 21 +- docs/event-based-agents/04-event-routing.md | 823 +++--------------- docs/event-based-agents/06-migration-plan.md | 494 +++-------- docs/review/box-architecture.md | 37 +- docs/review/box-session-scope.md | 509 +++-------- docs/review/box-test-coverage.md | 13 +- .../e2e/ensure-fake-provider-pipeline.mjs | 1 - skills/scripts/e2e/pipeline-debug-chat.mjs | 4 +- .../cases/agent-runner-ledger-invariants.yaml | 2 +- .../cases/agent-runner-qa-debug-chat.yaml | 1 + .../cases/agent-runner-runtime-chaos.yaml | 4 +- ...-combo-rag-compaction-tool-debug-chat.yaml | 2 +- ...l-agent-context-compaction-debug-chat.yaml | 2 +- ...t-multitool-rag-compaction-debug-chat.yaml | 2 +- ...allel-tools-rag-compaction-debug-chat.yaml | 2 +- ...-agent-tool-error-recovery-debug-chat.yaml | 2 +- ...ocal-agent-tool-loop-limit-debug-chat.yaml | 2 +- .../cases/mcp-stdio-tool-call.yaml | 2 +- .../agent-runner-async-db-readiness.mjs | 8 +- .../probes/agent-runner-behavior-matrix.mjs | 11 +- .../probes/agent-runner-fixture-contract.mjs | 4 +- .../agent-runner-ledger-concurrency.mjs | 4 +- .../probes/agent-runner-ledger-contention.mjs | 11 +- .../probes/agent-runner-ledger-invariants.mjs | 11 +- .../probes/agent-runner-ledger-stress.mjs | 11 +- .../probes/agent-runner-runtime-chaos.mjs | 2 +- .../langbot-testing/probes/pytest-probe.mjs | 2 +- skills/test/lbs-cli.test.ts | 6 +- src/langbot/pkg/agent/__init__.py | 7 +- src/langbot/pkg/agent/runner/__init__.py | 4 +- .../pkg/agent/runner/config_migration.py | 64 -- .../pkg/agent/runner/config_resolver.py | 199 +++++ .../pkg/agent/runner/context_builder.py | 3 + .../pkg/agent/runner/default_config.py | 4 +- .../pkg/agent/runner/execution_context.py | 319 +++++++ src/langbot/pkg/agent/runner/host_models.py | 17 +- src/langbot/pkg/agent/runner/orchestrator.py | 45 +- src/langbot/pkg/agent/runner/query_bridge.py | 6 +- .../pkg/agent/runner/query_entry_adapter.py | 183 ++-- .../pkg/agent/runner/resource_builder.py | 149 +++- .../pkg/agent/runner/resource_policy.py | 146 ++++ src/langbot/pkg/agent/runner/run_journal.py | 10 +- .../pkg/agent/runner/session_registry.py | 31 + .../pkg/api/http/controller/groups/agents.py | 10 +- .../controller/groups/pipelines/pipelines.py | 78 +- .../http/controller/groups/resources/tools.py | 78 +- src/langbot/pkg/api/http/service/agent.py | 26 +- src/langbot/pkg/api/http/service/pipeline.py | 79 +- src/langbot/pkg/box/service.py | 130 +-- src/langbot/pkg/core/stages/load_config.py | 38 - .../pkg/entity/persistence/metadata.py | 9 - .../alembic/versions/0001_baseline.py | 7 +- .../0006_normalize_mcp_remote_mode.py | 4 +- .../alembic/versions/0007_add_bot_admins.py | 95 +- ...ings.py => 0009_migrate_event_bindings.py} | 30 +- .../versions/0010_merge_mcp_agent_heads.py | 19 + .../0010_merge_mcp_resource_agent_heads.py | 19 - .../versions/0011_drop_legacy_bot_routing.py | 4 +- .../0012_add_monitoring_tool_calls.py | 67 ++ src/langbot/pkg/persistence/mgr.py | 58 +- src/langbot/pkg/persistence/migration.py | 40 - .../pkg/persistence/migrations/README.md | 36 - .../pkg/persistence/migrations/__init__.py | 0 .../migrations/dbm001_migrate_v3_config.py | 218 ----- .../dbm002_combine_quote_msg_config.py | 55 -- .../migrations/dbm003_n8n_config.py | 62 -- .../migrations/dbm004_rag_kb_uuid.py | 53 -- .../dbm005_pipeline_remove_cot_config.py | 53 -- .../migrations/dbm006_langflow_api_config.py | 58 -- .../dbm007_plugin_install_source.py | 44 - .../migrations/dbm008_plugin_config.py | 22 - .../dbm009_pipeline_extension_preferences.py | 20 - .../dbm010_pipeline_multi_knowledge_base.py | 105 --- .../dbm011_dify_base_prompt_config.py | 55 -- .../dbm012_pipeline_extensions_enable_all.py | 73 -- .../dbm013_knowledge_base_updated_at.py | 49 -- .../dbm014_space_account_support.py | 94 -- .../dbm015_model_source_tracking.py | 15 - .../dbm016_model_provider_refactor.py | 305 ------- .../dbm017_move_cloud_service_url.py | 25 - .../migrations/dbm018_add_emoji_support.py | 58 -- .../dbm019_monitoring_message_role.py | 24 - ...20_knowledge_engine_plugin_architecture.py | 161 ---- .../dbm021_merge_exception_handling.py | 74 -- .../migrations/dbm022_monitoring_user_name.py | 73 -- .../dbm023_model_fallback_config.py | 102 --- .../dbm024_wecombot_websocket_mode.py | 49 -- .../dbm025_bot_pipeline_routing_rules.py | 15 - .../dbm026_monitoring_tool_calls.py | 17 - .../pkg/pipeline/extension_preferences.py | 156 ++++ src/langbot/pkg/pipeline/pipelinemgr.py | 35 +- src/langbot/pkg/pipeline/preproc/preproc.py | 65 +- .../pkg/pipeline/process/handlers/chat.py | 8 +- src/langbot/pkg/platform/botmgr.py | 56 +- src/langbot/pkg/plugin/handler.py | 122 ++- src/langbot/pkg/provider/tools/errors.py | 9 + src/langbot/pkg/provider/tools/loaders/mcp.py | 82 +- .../pkg/provider/tools/loaders/plugin.py | 26 +- .../pkg/provider/tools/loaders/skill.py | 6 +- src/langbot/pkg/provider/tools/toolmgr.py | 212 ++++- src/langbot/pkg/utils/constants.py | 9 - src/langbot/templates/config.yaml | 6 - src/langbot/templates/legacy/command.json | 7 - src/langbot/templates/legacy/pipeline.json | 38 - src/langbot/templates/legacy/platform.json | 130 --- src/langbot/templates/legacy/provider.json | 161 ---- src/langbot/templates/legacy/system.json | 27 - .../persistence/test_migrations.py | 228 ++++- tests/integration/pipeline/test_full_flow.py | 11 +- tests/unit_tests/agent/conftest.py | 8 +- tests/unit_tests/agent/test_chat_handler.py | 69 +- .../unit_tests/agent/test_config_migration.py | 113 --- .../unit_tests/agent/test_config_resolver.py | 207 +++++ ...l.py => test_config_resolver_templates.py} | 12 +- .../agent/test_execution_context.py | 225 +++++ tests/unit_tests/agent/test_handler_auth.py | 32 +- .../agent/test_orchestrator_integration.py | 201 ++++- .../unit_tests/agent/test_resource_builder.py | 168 +++- .../unit_tests/agent/test_resource_policy.py | 91 ++ .../unit_tests/agent/test_session_registry.py | 74 +- .../api/service/test_agent_service.py | 188 +++- .../api/service/test_pipeline_service.py | 188 +++- .../unit_tests/api/test_agents_controller.py | 67 ++ .../api/test_pipelines_controller.py | 213 +++++ tests/unit_tests/api/test_tools_controller.py | 301 +++++++ tests/unit_tests/box/test_box_service.py | 259 +++--- tests/unit_tests/command/test_operator.py | 2 +- tests/unit_tests/core/test_bootutils_deps.py | 10 +- .../unit_tests/pipeline/test_chat_handler.py | 35 +- tests/unit_tests/pipeline/test_pipelinemgr.py | 94 +- tests/unit_tests/pipeline/test_preproc.py | 148 +++- .../unit_tests/platform/test_routing_rules.py | 147 ++++ .../platform/test_telegram_eba_adapter.py | 2 + tests/unit_tests/plugin/test_handler.py | 92 +- .../unit_tests/plugin/test_handler_actions.py | 522 +++++++++-- .../provider/test_mcp_remote_transport.py | 14 + .../unit_tests/provider/test_mcp_resources.py | 136 ++- tests/unit_tests/provider/test_skill_tools.py | 14 +- .../unit_tests/provider/test_tool_manager.py | 120 ++- .../provider/test_tool_source_routing.py | 157 ++++ tests/unit_tests/test_preproc.py | 30 +- tests/unit_tests/vector/test_mgr.py | 23 +- .../vector/test_valkey_search_filter.py | 10 +- tests/unit_tests/vector/test_vdb_base.py | 2 +- tests/utils/import_isolation.py | 1 - .../app/home/agents/AgentDetailContent.tsx | 9 +- .../agents/components/AgentFormComponent.tsx | 22 +- .../bot-form/EventBindingsEditor.tsx | 6 +- .../dynamic-form/ToolResourceSelectors.tsx | 164 ++-- .../components/MessageDetailsCard.tsx | 74 +- .../home/pipelines/PipelineDetailContent.tsx | 7 +- .../pipeline-form/PipelineFormComponent.tsx | 38 +- web/src/app/infra/entities/api/index.ts | 9 +- web/src/app/infra/http/BackendClient.ts | 10 +- web/src/app/wizard/page.tsx | 48 - web/src/i18n/locales/en-US.ts | 13 +- web/src/i18n/locales/es-ES.ts | 13 +- web/src/i18n/locales/ja-JP.ts | 13 +- web/src/i18n/locales/ru-RU.ts | 13 +- web/src/i18n/locales/th-TH.ts | 13 +- web/src/i18n/locales/vi-VN.ts | 13 +- web/src/i18n/locales/zh-Hans.ts | 11 +- web/src/i18n/locales/zh-Hant.ts | 6 +- web/tests/e2e/crud-smoke.spec.ts | 286 +++++- web/tests/e2e/fixtures/langbot-api.ts | 232 ++++- 171 files changed, 6958 insertions(+), 5385 deletions(-) delete mode 100644 src/langbot/pkg/agent/runner/config_migration.py create mode 100644 src/langbot/pkg/agent/runner/config_resolver.py create mode 100644 src/langbot/pkg/agent/runner/execution_context.py create mode 100644 src/langbot/pkg/agent/runner/resource_policy.py rename src/langbot/pkg/persistence/alembic/versions/{0009_migrate_routing_to_event_bindings.py => 0009_migrate_event_bindings.py} (81%) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py delete mode 100644 src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_resource_agent_heads.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py delete mode 100644 src/langbot/pkg/persistence/migration.py delete mode 100644 src/langbot/pkg/persistence/migrations/README.md delete mode 100644 src/langbot/pkg/persistence/migrations/__init__.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py delete mode 100644 src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py create mode 100644 src/langbot/pkg/pipeline/extension_preferences.py delete mode 100644 src/langbot/templates/legacy/command.json delete mode 100644 src/langbot/templates/legacy/pipeline.json delete mode 100644 src/langbot/templates/legacy/platform.json delete mode 100644 src/langbot/templates/legacy/provider.json delete mode 100644 src/langbot/templates/legacy/system.json delete mode 100644 tests/unit_tests/agent/test_config_migration.py create mode 100644 tests/unit_tests/agent/test_config_resolver.py rename tests/unit_tests/agent/{test_config_migration_full.py => test_config_resolver_templates.py} (78%) create mode 100644 tests/unit_tests/agent/test_execution_context.py create mode 100644 tests/unit_tests/agent/test_resource_policy.py create mode 100644 tests/unit_tests/api/test_agents_controller.py create mode 100644 tests/unit_tests/api/test_pipelines_controller.py create mode 100644 tests/unit_tests/api/test_tools_controller.py create mode 100644 tests/unit_tests/provider/test_tool_source_routing.py diff --git a/AGENTS.md b/AGENTS.md index a886d2e1a..82739895e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Config keys to verify in `data/config.yaml` / `src/langbot/templates/config.yaml ## Change Rules - HTTP API changes that should be agent-accessible must update the matching MCP tool in `src/langbot/pkg/api/mcp/server.py` and the relevant skill under `skills/` in the same pass. -- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`; do not add legacy `dbmXXX` migrations. +- New schema changes use Alembic under `src/langbot/pkg/persistence/alembic/versions/`. LangBot 4.x does not support upgrading 3.x databases. - New platform behavior belongs in platform adapters only for platform translation; pipeline/business logic belongs in `pkg/pipeline/` or services. - User-facing strings must support i18n (`en_US`, `zh_Hans`; include `ja_JP` where the repo already does). - Code comments and docstrings must be English. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ded90e7ea..853225ae4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,12 +52,13 @@ LangBot/ │ │ ├── api/ # HTTP API + MCP server mount │ │ ├── platform/ # IM adapters and runtime bot manager │ │ ├── pipeline/ # Message routing and pipeline stages -│ │ ├── provider/ # LLM runners, model manager, tools +│ │ ├── provider/ # Model providers and Host-owned tools +│ │ ├── agent/ # Agent/AgentRunner orchestration and run state │ │ ├── plugin/ # LangBot-side Plugin Runtime connector/handler │ │ ├── box/ # LangBot-side Box service/connector │ │ ├── skill/ # Skill metadata/activation integration │ │ ├── rag/ , vector/ # Knowledge-base and vector DB integration -│ │ ├── persistence/ # SQLAlchemy/SQLModel, Alembic, legacy migrations +│ │ ├── persistence/ # SQLAlchemy/SQLModel and Alembic migrations │ │ ├── storage/ # Local/S3 file storage abstraction │ │ └── config/, entity/, utils/, telemetry/, survey/ │ ├── libs/ # Vendored third-party platform SDKs @@ -80,7 +81,7 @@ Platform adapter → Controller → RuntimePipeline → PipelineStage chain - → RequestRunner / ToolManager / PluginRuntimeConnector / BoxService + → AgentRunner orchestrator / ToolManager / PluginRuntimeConnector / BoxService → response via adapter ``` @@ -107,7 +108,7 @@ Inbound platform messages enter through adapter-specific SDK callbacks. The comm 3. `MessageAggregator` batches/normalizes messages before adding a `Query` to `QueryPool`. 4. `Controller` in `pkg/pipeline/controller.py` selects queries subject to global pipeline concurrency and per-session concurrency. 5. `RuntimePipeline` in `pkg/pipeline/pipelinemgr.py` runs configured pipeline stages using a responsibility-chain style executor that supports generator stages. -6. The chat stage emits plugin events, calls a configured `RequestRunner`, handles streaming/non-streaming responses, records telemetry, and appends conversation history. +6. The chat stage emits plugin events and projects the current query into the AgentRunner Host orchestrator. The selected plugin AgentRunner returns streaming or final results while the Host owns authorization, tools, telemetry, and conversation history. 7. Output stages send text, cards, chunks, files, or error notices back through the original platform adapter. Pipeline components are registered by decorators and package import side effects. When adding a new stage, loader, runner, or adapter, check the corresponding preregistration mechanism instead of inventing a second registry. @@ -136,12 +137,12 @@ Important pieces: Pipelines are configuration-driven. Prefer adding a stage or extending an existing stage family over hard-coding behavior in platform adapters. -## Provider, RAG, and Tools +## Agents, Providers, RAG, and Tools -Provider code lives under `pkg/provider/`. +Agent orchestration lives under `pkg/agent/`; model providers and tools live under `pkg/provider/`. - `modelmgr/` manages configured model providers and requesters. -- `runners/` implements request runners such as the local agent runner and external workflow integrations. +- `pkg/agent/runner/` discovers plugin AgentRunner components, resolves bindings, constructs run-scoped context/resources, and records execution state. - `tools/toolmgr.py` aggregates tools from native tools, plugin tools, external MCP servers, and skill-authoring tools. - `tools/loaders/mcp.py` is the MCP client side: external MCP servers that LangBot connects to for agent tools. - RAG lives across `pkg/rag/`, `pkg/vector/`, model services, and plugin KnowledgeEngine actions. @@ -206,8 +207,8 @@ Persistence is centered on `pkg/persistence/mgr.py`. - SQLite is the default database; PostgreSQL is supported. - Models live under `pkg/entity/persistence/`. -- Fresh schemas are created from metadata, then legacy migrations run up to the frozen 3.x baseline, then Alembic migrations run to head. -- New schema changes should use Alembic under `pkg/persistence/alembic/versions/`; do not extend the frozen legacy migration chain. +- Fresh schemas are created from current metadata, then Alembic migrations run to head. LangBot 4.x does not upgrade 3.x databases. +- New schema changes use Alembic under `pkg/persistence/alembic/versions/`; there is no legacy migration chain in 4.x. Configuration starts from `src/langbot/templates/config.yaml` and is generated into `data/config.yaml` on first run. Most long-lived managers read from `ap.instance_config.data`. @@ -239,7 +240,7 @@ When one of these changes, update the others if the behavior or contract changed - New LLM tool source: extend `pkg/provider/tools/loaders/` and `ToolManager` intentionally. - New plugin component/API/protocol: change `langbot-plugin-sdk` first or in lockstep, then update LangBot bridge code. - New Box capability: change both `pkg/box/` and `langbot-plugin-sdk/src/langbot_plugin/box/`, plus config and tests. -- New database schema: add an Alembic migration, not a legacy `dbmXXX` migration. +- New database schema: add an Alembic migration. ## Design Biases diff --git a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md index 2e4a8ed4e..7905e8278 100644 --- a/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md +++ b/docs/agent-runner-pluginization/EVENT_BASED_AGENT.md @@ -80,13 +80,22 @@ EBA 后 `action.requested`(PROTOCOL_V1 §7.3,当前仅 telemetry 不执行 Host 必须校验:binding / platform action policy 是否授权该 action、actor / bot / workspace 是否允许、是否需要人工审批,以及当前 run session / caller identity 是否匹配。EBA 还可能预留 `delivery.requested`(请求投递到某 surface)。 Delivery 方面,event 不一定回复到当前聊天窗口:消息事件通常带 reply target;系统事件可能没有默认 reply target,需要 runner 返回 `action.requested` 或由 binding 的 delivery policy 决定投递位置(`DeliveryContext` 见 PROTOCOL_V1 §5.7)。 +当前 Host 会把 adapter 声明的通用 API 投影到 +`DeliveryContext.platform_capabilities.supported_apis`,并据此设置 +`supports_edit` / `supports_reaction`。该投影只供 runner 选择输出形态,不构成 +平台动作授权;合成测试 adapter 会移除副作用能力并抑制实际出站调用。 ## 7. 与 Context 协议的关系 EBA 事件进入 AgentRunner 时仍遵循 [AGENT_CONTEXT_PROTOCOL.md](./AGENT_CONTEXT_PROTOCOL.md):inline 当前事件、大 payload 用 raw/staged file ref、不默认 inline 完整 history、agent 按需通过 API 拉取、Host 保留 EventLog 和权限 guardrail。非消息事件可以被投影进 Transcript,但不能强制伪装为 user message;AgentRunner 根据 event type 自己决定是否纳入模型上下文。 -## 8. EBA 分支联调内容 +## 8. 当前集成状态 -外部 EBA 分支负责联调 EventGateway 完整实现、Pipeline / Agent 处理器路由、AgentBindingResolver 集成、事件绑定持久模型和 UI、`DeliveryContext` 完整实现、platform action permission model 和执行器、真实平台事件接入。 +当前分支已完成 EventRouter、Pipeline / Agent 平级处理器路由、Bot +`event_bindings` 持久化与 WebUI、AgentBinding 投影、路由 dry-run、合成测试事件、 +运行状态和真实 OneBot 非消息事件到 Agent 的闭环。Pipeline 消息链和独立 Agent +均复用同一个 AgentRunner orchestrator / context / result 协议。 -当前底座已完成:① Pipeline 消息链可投影 `message.received` 并在 AI Stage 复用 AgentRunner → ② 独立 Agent 可从 EBA 路由直接生成 `AgentBinding` → ③ context builder 从 event + binding 构造 runner context → ④ 引入 EventLog / Transcript。外部 EBA 分支在此基础上联调真实事件来源、处理器路由、binding persistence / UI 和 platform action。 +尚未落地的是 platform action permission model 和 `action.requested` 执行器;在显式 +action allowlist、binding policy、adapter capability 和审批模型完成前,该 result 仍只 +记录 telemetry,不执行平台副作用。 diff --git a/docs/agent-runner-pluginization/PROTOCOL_V1.md b/docs/agent-runner-pluginization/PROTOCOL_V1.md index 302c3bd33..1b6c8aa1c 100644 --- a/docs/agent-runner-pluginization/PROTOCOL_V1.md +++ b/docs/agent-runner-pluginization/PROTOCOL_V1.md @@ -295,6 +295,11 @@ class DeliveryContext(BaseModel): ``` Runner 可参考 delivery 能力决定返回 `message.delta`、`message.completed` 或 `action.requested`。 +平台事件进入独立 Agent 时,Host 会从当前 adapter 的 `get_supported_apis()` 投影 +`supports_edit`、`supports_reaction`,并把去重后的 API 名称写入 +`platform_capabilities.supported_apis`。这些字段只描述当前投递表面的能力,不授予 +平台动作权限;`action.requested` 仍受 §7.3 的 reserved 约束。合成路由测试使用的 +adapter 会过滤所有已知副作用 API,因此测试事件不会向 runner 宣告真实出站能力。 ### 5.8 ContextAccess diff --git a/docs/agent-runner-pluginization/STATUS.md b/docs/agent-runner-pluginization/STATUS.md index 88c7b4e64..04e83071a 100644 --- a/docs/agent-runner-pluginization/STATUS.md +++ b/docs/agent-runner-pluginization/STATUS.md @@ -2,7 +2,7 @@ 本文档是 `docs/agent-runner-pluginization/` 的状态事实源。协议 schema 仍以 [PROTOCOL_V1.md](./PROTOCOL_V1.md) 为准;测试步骤以 [AGENT_RUNNER_QA_GUIDE.md](./AGENT_RUNNER_QA_GUIDE.md) 为准;安全发布门槛以 [SECURITY_HARDENING.md](./SECURITY_HARDENING.md) 为准。 -状态快照日期:2026-06-23。 +状态快照日期:2026-07-12。 ## 实现状态 @@ -20,11 +20,12 @@ | Security boundary | Done | 当前口径降级为轻量边界:LangBot 保护自身持有资源;external harness 的 OS / process / network / workspace 风险由用户或部署环境承担;managed sandbox 不是当前承诺。 | | Steering control path | Done | claim 异常不再逃逸 consumer loop;queue 有上限;未 pull 的 claimed 输入在 run 结束时写 `steering.dropped` 审计终态。 | | SDK v1 contract closure | Done | SDK 提供 `AgentAPIError` / `AgentAPIException`、typed `SteeringPullResult`、未知 result type 宽容解析、result `sequence` 注入与取消传播。 | +| EBA processor routing | Done; clean-instance catalog gate pending | Bot `event_bindings`、Pipeline / Agent 平级路由、WebUI dry-run / 合成测试 / 状态、OneBot 非消息事件到 Agent 及平台回复已闭环;全新实例 Runner Marketplace 用例仍需独立空白环境。 | ## Spec 与实现已知差距 - `action.requested` 仍只作为 telemetry / reserved surface;platform action executor 不在本分支执行。 -- EventGateway / EventRouter 完整实现由外部 EBA 分支联调;本分支只提供 event-first host envelope / binding / run 入口。 +- `action.requested` 权限模型完成前,DeliveryContext 的 adapter capability 投影只用于输出决策,不提供平台动作执行权限。 - State 与 storage 的长期类型边界仍可继续收窄;当前合同只要求 JSON-safe state 与受控 storage API。 - `ToolResource.parameters` 已作为 best-effort full schema 由 Host 在构造 `ctx.resources` 时一次塞齐;无 schema 时 runner 仍需兼容 `parameters=None` 或按需调用 detail API。 - EventLog / Transcript 已提供显式 cleanup primitive;长期 retention 默认值、TTL 调度接入和 sandbox/workspace 文件清理仍是运维收尾项,应在 Runtime Control Plane 产品化前补齐。 @@ -44,7 +45,7 @@ | 范围 | 状态 | 最近证据 | | --- | --- | --- | -| LangBot Runtime Control Plane v2 foundation | Unit-pass; product E2E pending | 2026-06-23 `tests/unit_tests/agent`、`tests/unit_tests/plugin/test_handler_actions.py`、`tests/unit_tests/provider/test_skill_tools.py`、pipeline preproc/chat handler tests 和 Telegram EBA adapter tests 通过,覆盖 ledger、admin permissions、runtime heartbeat、claim/reconcile、orchestrator 持久化、取消传播、skill activation persistence 和插件化 runner pipeline path。 | +| LangBot Runtime Control Plane v2 foundation | Unit-pass; EBA product flow pass | 2026-07-12 事件路由与 Agent 协议针对性测试通过;WebUI 已验证 Quick Start 场景筛选、事件路由 dry-run / 合成派发、Runner 健康状态,以及真实 OneBot `group.member_joined` → Agent → `send_group_msg` 链路。clean-instance Runner Marketplace 用例因当前实例已有插件与 runner 未执行。 | | SDK AgentRunner control entities / proxy | Unit-pass | 2026-06-23 SDK `tests/api/entities/builtin/agent_runner`、`tests/api/proxies`、`tests/api/test_agent_tools_mcp_bridge.py`、`tests/runtime/plugin/test_mgr_agent_runner.py`、`tests/runtime/test_pull_api_handlers.py`、`tests/runtime/io/handlers/test_plugin_handler.py`、EBA event entities 和 message tests 通过,覆盖 typed entities、AgentRunAPIProxy、MCP bridge、runtime manager 与 pull API handlers。 | ## 历史高价值记录 diff --git a/docs/event-based-agents/00-overview.md b/docs/event-based-agents/00-overview.md index 2e6f77676..4f1f55c42 100644 --- a/docs/event-based-agents/00-overview.md +++ b/docs/event-based-agents/00-overview.md @@ -48,7 +48,9 @@ MessageAggregator (消息聚合) QueryPool → Controller → Pipeline (固定阶段链) │ │ │ ▼ - │ RequestRunner (local-agent / dify / n8n / ...) + │ AgentRunner Host orchestrator + │ ▼ + │ plugin AgentRunner │ ▼ adapter.reply_message() / adapter.send_message() @@ -71,13 +73,14 @@ adapter.reply_message() / adapter.send_message() ▼ EventBus (统一事件总线) │ - ▼ -EventRouter (事件路由引擎, 读取 Bot 的 event_handlers 配置) + ├─→ Plugin EventListener observers(始终广播,不参与响应仲裁) │ - ├─→ PipelineHandler — 现有流水线(完整 Stage 链) - ├─→ AgentHandler — 直接调用 RequestRunner(轻量 AI 处理) - ├─→ WebhookHandler — POST 到外部服务(Dify/n8n webhook 等) - └─→ PluginHandler — 分发给插件 EventListener + ▼ +EventRouter (读取 Bot 的 event_bindings) + │ + ├─→ Pipeline target — 完整 Stage 链,仅消息事件 + ├─→ Agent target — 独立 Agent,经插件 AgentRunner 执行 + └─→ discard — 明确丢弃 │ ▼ 统一平台 API @@ -141,20 +144,13 @@ pkg/platform/adapters/ 详见 [03-adapter-structure.md](./03-adapter-structure.md)。 -### 3.4 事件处理器(Event Handler) +### 3.4 事件响应目标与观察者 -> **2026-06 方向修订**:四种 Handler 分类法已演进为「事件 → 处理器」统一路由。Pipeline 与 Agent 是长期并存、场景不同的同级处理器:Pipeline 面向消息事件的完整 Stage 链,Agent 面向其声明支持的消息或非消息事件;插件 EventListener 保留为观察者角色。详见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本节保留原设计供对照。 +Pipeline 与 Agent 是长期并存、场景不同的同级处理器。Pipeline 保留完整 Stage 链,面向消息处理;Agent 是独立配置对象,选择一个已安装的插件 AgentRunner,并可声明消息或非消息事件能力。Bot 的 `event_bindings` 只负责把事件绑定到既有 Pipeline、独立 Agent 或 `discard`。 -四种处理器类型,用户在 WebUI 的 Bot 管理页面配置: +插件 EventListener 是观察者:事件先广播给有权限的监听器,随后路由器再选择一个响应目标。Webhook、Dify、n8n 等外部执行方式若需要作为响应者,应由对应 AgentRunner 插件表达,而不是增加另一套 Host Handler 主链。 -| 类型 | 说明 | 适用场景 | -|------|------|----------| -| **pipeline** | 现有流水线机制,完整的多 Stage 处理链(PreProcessor → MessageProcessor → PostProcessor 等) | 复杂消息处理,需要完整的预处理/后处理流程 | -| **agent** | 直接调用 RequestRunner(local-agent / dify / n8n / coze / dashscope / langflow / tbox),从 Pipeline 中解耦 | 轻量级 AI 处理、直接对接外部 LLMOps 平台处理各类事件 | -| **webhook** | 将事件 POST 到外部 URL,根据响应执行动作 | 对接自建服务、Dify/n8n 的 Webhook 触发器、自定义后端 | -| **plugin** | 分发给插件 EventListener 处理 | 插件自定义逻辑 | - -配置存储在 Bot 表的 `event_handlers` JSON 字段中,通过 WebUI 编排面板管理。 +现有 Pipeline 不会被转换为 Agent,Pipeline 内的 runner 配置也不会复制到独立 Agent。用户需要 Agent 时自行创建并绑定。 详见 [04-event-routing.md](./04-event-routing.md)。 @@ -173,8 +169,8 @@ pkg/platform/adapters/ | 1 | 事件处理器配置粒度 | 每个 Bot 独立配置 | Bot 是用户操作的核心单元,不同 Bot 可能对接不同业务场景 | | 2 | 适配器特有 API | 统一抽象 + `call_platform_api` 透传 | 通用 API 覆盖大部分场景,透传机制保证灵活性,避免每个适配器导出独立的类型化 API 包 | | 3 | 向后兼容策略 | 兼容层适配 | 保留旧事件类型和 API 作为新系统的 alias/wrapper,现有插件无需修改 | -| 4 | 处理器配置存储 | Bot 表新增 `event_handlers` JSON 字段 | 简单直接,避免新增关联表;替代现有 `use_pipeline_uuid` | -| 5 | Agent 处理器定位 | 从 Pipeline 中解耦 RequestRunner | 不是所有事件都需要完整 Pipeline Stage 链;Agent 处理器提供轻量级 AI 处理路径,支持所有现有 Runner | +| 4 | 处理器配置存储 | Bot 表使用 `event_bindings`,目标引用原始 Pipeline 或独立 Agent UUID | 路由关系不复制处理器配置,Pipeline/Agent 各自保持事实源 | +| 5 | Agent 处理器定位 | 独立 Agent + 插件 AgentRunner | Host 不再内置具体 runner;不同 AgentRunner 通过统一协议接入 | | 6 | 事件命名方式 | 命名空间式(`message.received`) | 清晰的分类层级,便于通配匹配(`message.*`),与 WebUI 配置天然对应 | ## 5. 文档索引 @@ -194,7 +190,7 @@ pkg/platform/adapters/ | 仓库 | 改动范围 | |------|----------| | **langbot-plugin-sdk** | 事件定义、实体模型、API 接口、适配器基类、通信协议扩展 | -| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot 实体扩展、数据库迁移、RequestRunner 解耦 | +| **LangBot**(后端) | 适配器实现、事件路由引擎、Bot/Agent 实体、AgentRunner Host 编排 | | **LangBot**(前端) | Bot 事件处理器编排面板 | | **langbot-wiki** | 新架构文档、插件开发指南更新、适配器开发指南 | | **langbot-plugin-demo** | 示例更新(使用新事件和 API) | diff --git a/docs/event-based-agents/01-event-system.md b/docs/event-based-agents/01-event-system.md index feb73c489..660c59d0d 100644 --- a/docs/event-based-agents/01-event-system.md +++ b/docs/event-based-agents/01-event-system.md @@ -490,18 +490,19 @@ class PlatformSpecificEvent(Event): │ 5. EventBus 分发 │ -6. EventRouter 查询 Bot 的 event_handlers 配置 - │ 匹配事件类型 → 找到对应的 Handler - │ 支持通配符:'message.*' 匹配所有消息事件 - │ 未匹配到 → 走默认 Handler(plugin,保持向后兼容) +6. EventBus 向有权限的 Plugin EventListener 广播观察者事件 + │ observer 不占用响应目标,也不参与 priority 仲裁 │ -7. Handler 处理事件 - │ PipelineHandler → 进入 Pipeline 流水线 - │ AgentHandler → 调用 RequestRunner - │ WebhookHandler → POST 到外部 URL - │ PluginHandler → 分发给插件 EventListener +7. EventRouter 查询 Bot 的 event_bindings + │ 精确/通配匹配 event_pattern,应用 filters 与 priority + │ 选择一个 Pipeline、Agent 或 discard 目标 │ -8. Handler 执行完毕,可能通过 API 执行响应动作 +8. 目标处理事件 + │ Pipeline → 进入完整 Pipeline 流水线(仅消息事件) + │ Agent → Host 编排已安装的插件 AgentRunner + │ discard → 不产生响应 + │ +9. 处理器执行完毕,可能通过 Host 授权 API 执行响应动作 (发消息、编辑消息、踢人、同意好友请求等) ``` diff --git a/docs/event-based-agents/04-event-routing.md b/docs/event-based-agents/04-event-routing.md index 1dd7e058c..cb2003b1c 100644 --- a/docs/event-based-agents/04-event-routing.md +++ b/docs/event-based-agents/04-event-routing.md @@ -1,747 +1,174 @@ # 事件路由与编排 -> **2026-06 方向修订**:本文档的四种 handler_type(pipeline / agent / webhook / plugin)分类法已被「事件 → 处理器(Agent / Pipeline)」统一编排取代,收编映射与新数据模型见 [07-agent-orchestration.md](./07-agent-orchestration.md)。本文档中的事件匹配规则(§4)、`use_pipeline_uuid` 路由迁移策略(§6)、WebUI 交互骨架(§7)与 webhook 请求/响应格式(§5.4)仍然有效。 +> 状态:当前实施模型(2026-07-12)。本文以 Pipeline / Agent 平级并存为准,不再保留早期 `pipeline / agent / webhook / plugin` 四种 Handler 草案。 -## 1. 概述 +## 1. 路由边界 -事件路由是 EBA 架构的核心机制:事件从适配器产生后,经由 EventBus 进入 EventRouter,由 EventRouter 根据 Bot 的配置将事件分发到对应的处理器(Handler)。 +EBA 将事件处理拆成两个互不替代的阶段: -**配置方式**:用户在 WebUI 的 Bot 管理页面通过可视化编排面板管理事件处理器配置,配置数据存储在数据库的 Bot 表 `event_handlers` JSON 字段中。 +1. **观察者广播**:EventBus 把事件广播给有权限的插件 EventListener。观察者可记录、同步或执行受控副作用,但不占用响应目标。 +2. **响应者仲裁**:EventRouter 按 Bot 的 `event_bindings` 选择一个 Pipeline、独立 Agent 或 `discard`。 + +Pipeline 与 Agent 是平级处理器: + +| 处理器 | 配置事实源 | 执行路径 | 事件范围 | +| --- | --- | --- | --- | +| Pipeline | Pipeline 表与完整 Stage 配置 | MessageAggregator -> QueryPool -> RuntimePipeline | 消息事件,首版为 `message.received` | +| Agent | Agent 表中的 runner 与 runner config | AgentRunner Host orchestrator -> plugin AgentRunner | Agent/Runner 声明支持的消息或非消息事件 | +| discard | 无处理器配置 | 明确结束路由 | 任意事件 | + +插件 EventListener 不是第三种响应目标。Webhook、Dify、n8n、Coze 等外部系统需要响应事件时,由对应 AgentRunner 插件承接。 ## 2. 数据模型 -### 2.1 Bot 实体扩展 +### 2.1 独立 Agent -在 `bots` 表新增 `event_handlers` 字段: +Agent 保存自己的 Runner 选择与配置,不嵌入 Pipeline,也不复制 Pipeline 的 AI stage: ```python -class Bot(Base): - __tablename__ = "bots" - - uuid: str # 主键 +class Agent(Base): + uuid: str name: str description: str - adapter: str - adapter_config: dict # JSON - enable: bool - - # 新增 - event_handlers: list # JSON — 事件处理器配置列表 - - # 保留(过渡期后弃用) - use_pipeline_name: str # deprecated - use_pipeline_uuid: str # deprecated - - created_at: datetime - updated_at: datetime + emoji: str + kind: str # 固定为 "agent" + component_ref: str # AgentRunner id + config: dict # runner + runner_config + enabled: bool + supported_event_patterns: list[str] ``` -### 2.2 EventHandler 配置结构 +当前配置形状: -`event_handlers` 字段存储一个 JSON 数组,每个元素定义一条事件路由规则: +```json +{ + "runner": { + "id": "plugin:langbot-team/LocalAgent/default" + }, + "runner_config": { + "plugin:langbot-team/LocalAgent/default": { + "model": { + "primary": "model-uuid", + "fallbacks": [] + } + } + } +} +``` + +Runner id 来自已安装插件的 AgentRunner manifest。Host 不维护 LocalAgent、Dify 或其他具体实现的内置分支。 + +### 2.2 EventBinding + +Bot 维护事件到处理器的引用: ```python -class EventHandlerConfig(pydantic.BaseModel): - """单条事件处理器配置""" - - event_type: str - """匹配的事件类型 - - 支持精确匹配和通配符: - - "message.received" — 精确匹配 - - "message.*" — 匹配 message 命名空间下所有事件 - - "group.*" — 匹配 group 命名空间下所有事件 - - "*" — 匹配所有事件(兜底) - """ - - handler_type: str - """处理器类型: "pipeline" | "agent" | "webhook" | "plugin" """ - - handler_config: dict = {} - """处理器的具体配置,结构取决于 handler_type""" - - enabled: bool = True - """是否启用此规则""" - - priority: int = 0 - """优先级,数字越大越先匹配(同一事件类型有多条规则时)""" - - description: str = "" - """规则描述(供 WebUI 显示)""" +class EventBinding(BaseModel): + id: str + event_pattern: str # 精确、namespace.* 或 * + target_type: str # agent | pipeline | discard + target_uuid: str | None # Agent/Pipeline 原始 UUID + filters: list[dict] + priority: int + enabled: bool + description: str ``` -### 2.3 各 Handler 类型的 handler_config 结构 - -#### pipeline - -```json -{ - "handler_type": "pipeline", - "handler_config": { - "pipeline_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - } -} -``` - -将事件作为消息事件传入现有 Pipeline 流水线。仅适用于 `message.received` 事件。 - -#### agent - -```json -{ - "handler_type": "agent", - "handler_config": { - "runner": "local-agent", - "runner_config": { - "model_uuid": "...", - "prompt": "你是一个群组助理,请处理以下事件:{event_summary}", - "tools_enabled": true - } - } -} -``` - -```json -{ - "handler_type": "agent", - "handler_config": { - "runner": "dify-service-api", - "runner_config": { - "base_url": "https://api.dify.ai/v1", - "api_key": "...", - "app_type": "agent" - } - } -} -``` - -直接调用 RequestRunner 处理事件。可用的 runner 包括: -- `local-agent` — 内置 LLM Agent -- `dify-service-api` — Dify 平台 -- `n8n-service-api` — n8n 工作流 -- `coze-api` — Coze (扣子) -- `dashscope-app-api` — 阿里百炼 -- `langflow-api` — Langflow -- `tbox-app-api` — 蚂蚁 Tbox - -Agent 处理器不经过 Pipeline 的多 Stage 流程,而是直接构建上下文并调用 Runner。适用于所有事件类型。 - -**Agent Handler 与 Pipeline 的关系**: -- Pipeline 是完整的多 Stage 处理链(PreProcessor → MessageProcessor(内含Runner) → PostProcessor → ...),适合复杂消息处理 -- Agent Handler 是轻量级的,直接调用 Runner,跳过 PreProcessor/PostProcessor 等阶段 -- Pipeline 内部的 AI Stage 仍然使用 Runner,所以 Runner 本身被两种 Handler 共享 -- 用户可以根据场景选择:消息处理用 Pipeline(更多控制),其他事件用 Agent(更直接) - -#### webhook - -```json -{ - "handler_type": "webhook", - "handler_config": { - "url": "https://example.com/webhook/langbot-events", - "method": "POST", - "headers": { - "Authorization": "Bearer xxx" - }, - "timeout": 30, - "retry_count": 3, - "retry_interval": 5, - "response_actions": true - } -} -``` - -将事件序列化为 JSON POST 到外部 URL。支持的特性: -- **认证**:通过 headers 配置(Bearer Token、API Key 等) -- **重试**:配置重试次数和间隔 -- **响应动作**:如果 `response_actions` 为 true,解析响应 JSON 中的 `actions` 字段并执行(如发送消息、同意好友请求等) - -Webhook 请求体格式: - -```json -{ - "event": { - "type": "group.member_joined", - "timestamp": 1700000000.0, - "bot_uuid": "...", - "adapter_name": "telegram", - "group": { "id": "...", "name": "..." }, - "member": { "id": "...", "nickname": "..." } - }, - "bot": { - "uuid": "...", - "name": "...", - "adapter": "telegram" - } -} -``` - -响应体格式(当 `response_actions` 为 true 时): - -```json -{ - "actions": [ - { - "type": "send_message", - "params": { - "target_type": "group", - "target_id": "123456", - "message": [{ "type": "Plain", "text": "欢迎新成员!" }] - } - }, - { - "type": "call_platform_api", - "params": { - "action": "pin_message", - "params": { "chat_id": "123456", "message_id": "789" } - } - } - ] -} -``` - -#### plugin - -```json -{ - "handler_type": "plugin", - "handler_config": { - "plugin_filter": [] - } -} -``` - -将事件分发给插件的 EventListener 处理。 - -- `plugin_filter`:可选的插件名过滤列表,为空表示分发给所有插件 -- 沿用现有的插件事件分发机制(按优先级遍历插件,支持 `prevent_postorder`) - -### 2.4 完整配置示例 - -一个 Bot 的 `event_handlers` 配置示例: +示例: ```json [ - { - "event_type": "message.received", - "handler_type": "pipeline", - "handler_config": { - "pipeline_uuid": "default-pipeline-uuid" - }, - "enabled": true, - "priority": 10, - "description": "消息事件使用默认流水线处理" - }, - { - "event_type": "group.member_joined", - "handler_type": "agent", - "handler_config": { - "runner": "local-agent", - "runner_config": { - "model_uuid": "gpt-4o-mini", - "prompt": "有新成员 {member_name} 加入了群组 {group_name},请生成一条欢迎消息。" - } - }, - "enabled": true, - "priority": 0, - "description": "新成员入群时用 AI 生成欢迎消息" - }, - { - "event_type": "friend.request_received", - "handler_type": "webhook", - "handler_config": { - "url": "https://my-server.com/api/friend-request", - "response_actions": true - }, - "enabled": true, - "priority": 0, - "description": "好友请求转发到自建服务处理" - }, - { - "event_type": "*", - "handler_type": "plugin", - "handler_config": {}, - "enabled": true, - "priority": -100, - "description": "所有事件兜底发给插件处理" - } + { + "id": "binding-message", + "event_pattern": "message.received", + "target_type": "pipeline", + "target_uuid": "pipeline-uuid", + "filters": [], + "priority": 100, + "enabled": true + }, + { + "id": "binding-member-joined", + "event_pattern": "group.member_joined", + "target_type": "agent", + "target_uuid": "agent-uuid", + "filters": [], + "priority": 50, + "enabled": true + } ] ``` -## 3. EventBus 设计 +Binding 只保存引用与路由条件。它不复制 Pipeline 或 Agent 配置。 -EventBus 是事件的中转站,接收来自各个 RuntimeBot 的事件,交由 EventRouter 处理。 +## 3. 匹配与仲裁 -```python -class EventBus: - """事件总线""" +事件模式支持: - def __init__(self, ap: Application): - self.ap = ap - self.event_router = EventRouter(ap) +- 精确匹配:`group.member_joined` +- 命名空间通配:`group.*` +- 全局通配:`*` - async def emit( - self, - event: Event, - adapter: AbstractPlatformAdapter, - ): - """接收并分发事件 +路由按以下顺序处理: - Args: - event: 统一事件对象 - adapter: 产生此事件的适配器实例 - """ - # 1. 全局事件日志 - self.ap.logger.debug( - f"EventBus: {event.type} from bot {event.bot_uuid}" - ) +1. 忽略 `enabled = false` 的 binding。 +2. 检查 `event_pattern` 与结构化 filters。 +3. 校验目标存在、启用且声明支持该事件。 +4. 按 `priority` 从高到低选择;同优先级按稳定列表顺序。 +5. 只执行一个响应目标。 - # 2. 交由 EventRouter 路由处理 - await self.event_router.route(event, adapter) +Pipeline 目标只能匹配消息事件。非消息事件不得伪装成用户文本塞进 Pipeline。 + +## 4. 执行流程 + +```text +Platform adapter + -> normalized event + -> EventBus + -> authorized Plugin EventListener observers + -> EventRouter + -> Pipeline target -> full Pipeline stage chain + -> Agent target -> AgentRunner Host orchestrator + -> discard -> stop + -> Host delivery/platform API ``` -## 4. EventRouter 设计 +### 4.1 Pipeline target -EventRouter 是事件路由引擎,根据 Bot 的 `event_handlers` 配置决定事件的处理方式。 +消息事件按原有方式构造 Query,经 MessageAggregator、QueryPool 和完整 Pipeline Stage 链执行。Pipeline 可以继续使用 AgentRunner 作为 AI stage 的实现,但 Pipeline 本身不会因此变成 Agent。 -```python -class EventRouter: - """事件路由引擎""" +### 4.2 Agent target - def __init__(self, ap: Application): - self.ap = ap - self.handlers: dict[str, AbstractEventHandler] = { - "pipeline": PipelineHandler(ap), - "agent": AgentHandler(ap), - "webhook": WebhookHandler(ap), - "plugin": PluginHandler(ap), - } +Host 读取独立 Agent 的 Runner id/config,构造 event-first context、run-scoped resources 与 delivery policy,再调用插件 AgentRunner。Runner 输出由 Host 统一归一化、记录和投递。 - async def route( - self, - event: Event, - adapter: AbstractPlatformAdapter, - ): - """路由事件到对应处理器""" +AgentRunner 可通过 SDK/Python `AgentRunAPIProxy.call_tool` 或 SDK-owned scoped MCP bridge 回调 Host 能力。两条路径都映射到 `PluginToRuntimeAction.CALL_TOOL`,使用相同的 run authorization、Host execution Query、ToolManager 和 Box session 规则。Box session 是 Host canonical scope 的固定长度安全哈希;同一平台会话稳定、不同 scope 隔离、缺少 identity 时 fail closed,Runner 不配置 sandbox scope。 - # 1. 获取 Bot 配置 - bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid) - if not bot: - return +### 4.3 Observer side effects - # 2. 获取事件处理器配置 - event_handlers = bot.bot_entity.event_handlers or [] +观察者与响应者可以同时工作。为避免编辑、reaction 等合成事件重复触发不可逆操作,Host 应按事件能力和授权过滤观察者可用 API,并记录副作用结果。Observer 广播不作为 fallback 响应。 - # 3. 匹配规则(按 priority 降序排列) - matched_handlers = self._match_handlers(event.type, event_handlers) +## 5. Pipeline 与 Agent 的并存规则 - if not matched_handlers: - # 未匹配到任何规则 → 默认交给插件处理(向后兼容) - await self.handlers["plugin"].handle(event, adapter, {}) - return +1. Pipeline 与 Agent 保留各自的持久化、编辑和执行语义。 +2. 处理器聚合页面可以统一展示二者,但不会创建第三份处理器记录。 +3. 旧 Pipeline 仍是 Pipeline;其 runner config 不迁移、不复制为独立 Agent。 +4. 需要 Agent 的用户新建 Agent、选择已安装 AgentRunner,再建立 event binding。 +5. 一个 Bot 可按不同事件同时绑定 Pipeline 与 Agent。 - # 4. 执行第一个匹配的 Handler - # (未来可扩展为多个 Handler 串行/并行执行) - handler_config = matched_handlers[0] - handler = self.handlers.get(handler_config.handler_type) +## 6. WebUI 约束 - if handler: - await handler.handle(event, adapter, handler_config.handler_config) - else: - self.ap.logger.warning( - f"Unknown handler type: {handler_config.handler_type}" - ) +处理器入口展示带类型标识的 Agent 与 Pipeline。Bot 事件编排器应: - def _match_handlers( - self, - event_type: str, - handlers: list[EventHandlerConfig], - ) -> list[EventHandlerConfig]: - """匹配事件类型到处理器配置 +- 按 adapter manifest 展示可用事件; +- 非消息事件不提供 Pipeline 目标; +- Agent 目标按 `supported_event_patterns` 过滤; +- 展示 priority、filters、enabled 与 discard; +- 保存前校验目标 UUID 与 `target_type` 一致。 - 匹配规则: - 1. 精确匹配:event_type == handler.event_type - 2. 命名空间通配:handler.event_type 为 "message.*" 时匹配所有 "message.xxx" - 3. 全局通配:handler.event_type 为 "*" 时匹配所有事件 - 4. 按 priority 降序排列 - 5. 只返回 enabled=True 的规则 - """ - matched = [] - for handler in handlers: - if not handler.enabled: - continue - if self._event_type_matches(event_type, handler.event_type): - matched.append(handler) +Pipeline 的配置、Debug Chat 和 Monitoring 继续使用 Pipeline 页面;Agent 使用独立表单配置 Runner 与事件能力。 - matched.sort(key=lambda h: h.priority, reverse=True) - return matched +## 7. 版本与迁移边界 - @staticmethod - def _event_type_matches(event_type: str, pattern: str) -> bool: - """判断事件类型是否匹配模式""" - if pattern == "*": - return True - if pattern == event_type: - return True - if pattern.endswith(".*"): - namespace = pattern[:-2] - return event_type.startswith(namespace + ".") - return False -``` +此功能按当前 4.x schema 直接实现,不提供 LangBot 3.x 数据库或配置升级路径,也不读取旧 Runner 字段作为 fallback。旧 Pipeline 中的 runner 配置不会生成独立 Agent;用户按新产品模型添加 Agent 即可。 -## 5. 事件处理器(Handler)实现 - -### 5.1 Handler 基类 - -```python -class AbstractEventHandler(abc.ABC): - """事件处理器基类""" - - def __init__(self, ap: Application): - self.ap = ap - - @abc.abstractmethod - async def handle( - self, - event: Event, - adapter: AbstractPlatformAdapter, - config: dict, - ) -> None: - """处理事件 - - Args: - event: 统一事件对象 - adapter: 适配器实例(用于调用平台 API 发送响应) - config: handler_config 配置 - """ - ... -``` - -### 5.2 PipelineHandler - -将消息事件注入现有 Pipeline 流水线处理。 - -```python -class PipelineHandler(AbstractEventHandler): - """Pipeline 处理器 — 将事件送入现有 Pipeline 流水线""" - - async def handle(self, event, adapter, config): - pipeline_uuid = config.get("pipeline_uuid") - - if not isinstance(event, MessageReceivedEvent): - self.ap.logger.warning( - f"PipelineHandler only supports MessageReceivedEvent, " - f"got {event.type}" - ) - return - - # 将 MessageReceivedEvent 转换为现有的 Query 并投入 QueryPool - # 复用现有的 MessageAggregator + QueryPool + Pipeline 机制 - launcher_type = ( - LauncherTypes.PERSON - if event.chat_type == ChatType.PRIVATE - else LauncherTypes.GROUP - ) - - await self.ap.msg_aggregator.add_message( - bot_uuid=event.bot_uuid, - launcher_type=launcher_type, - launcher_id=event.chat_id, - sender_id=event.sender.id, - message_event=event.to_legacy_event(), # 转换为 FriendMessage/GroupMessage - message_chain=event.message_chain, - adapter=adapter, - pipeline_uuid=pipeline_uuid, - ) -``` - -### 5.3 AgentHandler - -直接调用 RequestRunner 处理事件,不经过 Pipeline Stage 链。 - -```python -class AgentHandler(AbstractEventHandler): - """Agent 处理器 — 直接调用 RequestRunner 处理事件""" - - async def handle(self, event, adapter, config): - runner_name = config.get("runner", "local-agent") - runner_config = config.get("runner_config", {}) - - # 1. 查找 Runner 类 - runner_cls = None - for r in preregistered_runners: - if r.name == runner_name: - runner_cls = r - break - - if not runner_cls: - self.ap.logger.error(f"Runner not found: {runner_name}") - return - - # 2. 构建事件上下文(将事件信息整理为 Runner 可处理的格式) - event_context = self._build_event_context(event, runner_config) - - # 3. 实例化并调用 Runner - runner = runner_cls(self.ap, self._build_runner_pipeline_config(config)) - - response_messages = [] - async for result in runner.run(event_context): - response_messages.append(result) - - # 4. 发送响应(如果 Runner 产生了回复) - if response_messages and isinstance(event, MessageReceivedEvent): - # 将 Runner 输出转换为 MessageChain 并回复 - reply_chain = self._build_reply_chain(response_messages) - await adapter.reply_message(event, reply_chain) - - def _build_event_context(self, event, runner_config): - """将事件构建为 Runner 可处理的上下文 - - 对于消息事件,直接使用消息内容。 - 对于其他事件,根据 runner_config 中的 prompt 模板生成描述文本。 - """ - ... - - def _build_runner_pipeline_config(self, config): - """将 handler_config 转换为 Runner 需要的 pipeline_config 格式""" - ... -``` - -### 5.4 WebhookHandler - -将事件 POST 到外部 URL。 - -```python -class WebhookHandler(AbstractEventHandler): - """Webhook 处理器 — 将事件 POST 到外部 URL""" - - async def handle(self, event, adapter, config): - url = config.get("url") - method = config.get("method", "POST") - headers = config.get("headers", {}) - timeout = config.get("timeout", 30) - retry_count = config.get("retry_count", 3) - response_actions = config.get("response_actions", False) - - # 1. 构建请求体 - bot = await self.ap.platform_mgr.get_bot_by_uuid(event.bot_uuid) - payload = { - "event": event.model_dump(), - "bot": { - "uuid": bot.bot_entity.uuid, - "name": bot.bot_entity.name, - "adapter": bot.bot_entity.adapter, - } - } - - # 2. 发送请求(带重试) - response = await self._send_with_retry( - url, method, headers, payload, timeout, retry_count - ) - - # 3. 处理响应动作 - if response_actions and response: - await self._execute_response_actions( - response, adapter, event - ) - - async def _execute_response_actions(self, response, adapter, event): - """执行响应中的动作列表""" - actions = response.get("actions", []) - for action in actions: - action_type = action.get("type") - params = action.get("params", {}) - - if action_type == "send_message": - chain = MessageChain.model_validate(params.get("message", [])) - await adapter.send_message( - params["target_type"], - params["target_id"], - chain, - ) - elif action_type == "reply": - chain = MessageChain.model_validate(params.get("message", [])) - await adapter.reply_message(event, chain) - elif action_type == "call_platform_api": - await adapter.call_platform_api( - params["action"], - params.get("params", {}), - ) - elif action_type == "approve_friend_request": - await adapter.approve_friend_request( - params["request_id"], - params.get("approve", True), - ) - # ... 更多动作类型 -``` - -### 5.5 PluginHandler - -将事件分发给插件的 EventListener。 - -```python -class PluginHandler(AbstractEventHandler): - """Plugin 处理器 — 分发给插件 EventListener""" - - async def handle(self, event, adapter, config): - plugin_filter = config.get("plugin_filter", []) - - # 复用现有的插件事件分发机制 - # 通过 plugin_connector 将事件发送给 Plugin Runtime - await self.ap.plugin_connector.emit_event( - event=event, - adapter=adapter, - plugin_filter=plugin_filter, - ) -``` - -## 6. use_pipeline_uuid 迁移 - -### 6.1 自动迁移 - -数据库迁移脚本将现有的 `use_pipeline_uuid` 自动转换为 `event_handlers`: - -这里迁移的只有 Bot 路由字段:binding 仍指向原 Pipeline,不会新建 Agent,也不会复制 Pipeline 内嵌的 runner 配置。 - -```python -# 迁移逻辑 -for bot in all_bots: - if bot.use_pipeline_uuid and not bot.event_handlers: - bot.event_handlers = [ - { - "event_type": "message.received", - "handler_type": "pipeline", - "handler_config": { - "pipeline_uuid": bot.use_pipeline_uuid - }, - "enabled": True, - "priority": 10, - "description": "Auto-migrated from use_pipeline_uuid" - }, - { - "event_type": "*", - "handler_type": "plugin", - "handler_config": {}, - "enabled": True, - "priority": -100, - "description": "Default plugin handler" - } - ] -``` - -### 6.2 过渡期兼容 - -在过渡期内,如果 `event_handlers` 为空且 `use_pipeline_uuid` 非空,EventRouter 自动回退到旧行为: - -```python -# EventRouter.route() 中的兼容逻辑 -if not event_handlers and bot.bot_entity.use_pipeline_uuid: - # 回退:消息事件走 Pipeline,其他事件走 Plugin - if isinstance(event, MessageReceivedEvent): - await self.handlers["pipeline"].handle( - event, adapter, - {"pipeline_uuid": bot.bot_entity.use_pipeline_uuid} - ) - else: - await self.handlers["plugin"].handle(event, adapter, {}) - return -``` - -## 7. WebUI 编排面板数据模型 - -### 7.1 交互设计概要 - -在 WebUI 的 Bot 管理页面,新增"事件处理器"标签页(或区域),呈现为一个**规则列表**: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 事件处理器 [+ 添加规则] │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─ 规则 1 ─────────────────────────────────── [启用] [删除] ─┐ │ -│ │ 事件类型: [message.received ▾] │ │ -│ │ 处理器: [Pipeline ▾] │ │ -│ │ Pipeline: [默认流水线 ▾] │ │ -│ │ 优先级: [10] │ │ -│ │ 描述: 消息事件使用默认流水线处理 │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 规则 2 ─────────────────────────────────── [启用] [删除] ─┐ │ -│ │ 事件类型: [group.member_joined ▾] │ │ -│ │ 处理器: [Agent ▾] │ │ -│ │ Runner: [local-agent ▾] │ │ -│ │ 模型: [gpt-4o-mini ▾] │ │ -│ │ Prompt: [有新成员加入...] │ │ -│ │ 优先级: [0] │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─ 规则 3 (兜底) ──────────────────────────── [启用] [删除] ─┐ │ -│ │ 事件类型: [* ▾] │ │ -│ │ 处理器: [Plugin ▾] │ │ -│ │ 优先级: [-100] │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### 7.2 前端数据结构 - -```typescript -interface EventHandlerRule { - event_type: string; // 下拉选择,选项从适配器 manifest 的 supported_events 获取 - handler_type: string; // "pipeline" | "agent" | "webhook" | "plugin" - handler_config: Record; // 根据 handler_type 动态渲染不同的配置表单 - enabled: boolean; - priority: number; - description: string; -} - -// Bot 编辑接口扩展 -interface BotConfig { - uuid: string; - name: string; - adapter: string; - adapter_config: Record; - enable: boolean; - event_handlers: EventHandlerRule[]; // 新增 -} -``` - -### 7.3 事件类型下拉选项 - -从 Bot 关联的适配器 manifest 中获取 `supported_events`,加上通配符选项: - -``` -- message.received -- message.edited -- message.deleted -- message.reaction -- feedback.received -- group.member_joined -- group.member_left -- group.member_banned -- group.info_updated -- friend.request_received -- friend.added -- bot.invited_to_group -- bot.removed_from_group -- bot.muted -- bot.unmuted -- platform.specific -───────────────── -- message.* (所有消息事件) -- feedback.* (所有反馈事件) -- group.* (所有群组事件) -- friend.* (所有好友事件) -- bot.* (所有 Bot 事件) -- * (所有事件) -``` - -### 7.4 HTTP API - -``` -GET /api/v1/bots/{uuid}/event-handlers 获取 Bot 的事件处理器配置 -PUT /api/v1/bots/{uuid}/event-handlers 更新 Bot 的事件处理器配置 -GET /api/v1/adapters/{name}/supported-events 获取适配器支持的事件类型 -GET /api/v1/adapters/{name}/supported-apis 获取适配器支持的 API -``` +详见 [07-agent-orchestration.md](./07-agent-orchestration.md) 与 [08-agent-page-and-event-orchestration.md](./08-agent-page-and-event-orchestration.md)。 diff --git a/docs/event-based-agents/06-migration-plan.md b/docs/event-based-agents/06-migration-plan.md index 627ff5cd8..a39d69add 100644 --- a/docs/event-based-agents/06-migration-plan.md +++ b/docs/event-based-agents/06-migration-plan.md @@ -1,433 +1,145 @@ -# 分阶段迁移计划 +# EBA 分阶段实施计划 -> **2026-06 方向修订**:Phase 3 的「四种 Handler 框架」与 Phase 5 的编排面板形态,按 [07-agent-orchestration.md](./07-agent-orchestration.md) 调整为「事件 → 处理器」统一路由。EventRouter 通过 `target_type` 在同级 Pipeline / Agent 间选择;二者分别保留自己的实体、配置和执行链。阶段划分、依赖关系与验收标准按 Processor 模型解读;发布节奏见 07 §5「发布火车」。 +> 更新:2026-07-12。文件名沿用早期设计,但这里的“迁移”仅指代码架构逐步接入 EBA,不代表 LangBot 3.x 数据库或配置升级。 -## 1. 概述 +## 1. 发布边界 -EBA 架构涉及 langbot-plugin-sdk、LangBot 后端、LangBot 前端、文档和示例插件等多个仓库的改动。为降低风险、保证系统稳定性,采用分阶段渐进式迁移策略。 +EBA 跨越 SDK、平台适配器、LangBot Host、WebUI 与插件生态,按可验证阶段落地。当前发布遵守以下硬边界: -### 1.1 阶段总览 +- LangBot 4.x 不支持从 3.x 数据库或配置升级;不保留 legacy migration chain、旧 JSON 模板或旧 Runner 字段读取。 +- Pipeline 与 Agent 平级且长期并存,分别保留持久化模型与执行链。 +- 现有 Pipeline 不迁移为 Agent,Pipeline 内的 runner config 不复制到 Agent。 +- 用户需要 Agent 时新建独立 Agent并选择已安装的 AgentRunner。 +- Host 不按 LocalAgent id 做运行时、Box 或 WebUI 特判。 +- AgentRunner 的 SDK/Python 与 scoped MCP bridge 回调共享 Host 授权与事件 session 规则。 -| 阶段 | 名称 | 范围 | 依赖 | -|------|------|------|------| -| Phase 1 | SDK 实体层 | langbot-plugin-sdk | 无 | -| Phase 2 | 适配器重构 | LangBot 后端 | Phase 1 | -| Phase 3 | 核心系统 | LangBot 后端 | Phase 2 | -| Phase 4 | 插件 SDK 集成 | langbot-plugin-sdk + LangBot | Phase 3 | -| Phase 5 | WebUI 编排面板 | LangBot 前端 | Phase 3 | -| Phase 6 | 文档与示例 | langbot-wiki + langbot-plugin-demo | Phase 4, 5 | +## 2. 阶段总览 -### 1.2 核心原则 +| 阶段 | 目标 | 主要仓库 | 完成条件 | +| --- | --- | --- | --- | +| P0 | SDK 事件、能力与 AgentRunner 协议 | `langbot-plugin-sdk` | typed entities、manifest、proxy、runtime action 通过测试 | +| P1 | 平台适配器 EBA 化 | LangBot + SDK | 事件转换、能力声明、通用/透传 API 通过 adapter checklist | +| P2 | Host 观察者与响应者路由 | LangBot backend | observer 广播 + Pipeline/Agent/discard 单目标仲裁可运行 | +| P3 | 独立 Agent 与 Runner 注册 | LangBot backend + plugins | Agent CRUD、registry、run authorization、delivery 可运行 | +| P4 | WebUI 处理器与事件编排 | LangBot web | Agent/Pipeline 聚合入口和 Bot binding 编辑器可用 | +| P5 | 发布门禁与文档 | LangBot skills/docs + runner plugins | 单测、UI E2E、真实 adapter smoke 和插件预检通过 | -- **每个阶段结束后系统可运行**:任何阶段完成后,现有功能不受影响 -- **向后兼容贯穿全程**:旧接口在整个迁移期间保持可用 -- **先 SDK 后实现**:先定义好接口和模型,再做具体实现 -- **先核心适配器后边缘**:优先迁移用户量大的适配器 +## 3. P0:SDK 契约 ---- +### 工作项 -## 2. Phase 1:SDK 实体层 +- 定义规范化平台事件、actor/subject/conversation/delivery context。 +- 定义 adapter `supported_events`、`supported_apis` 与平台透传 API。 +- 定义 AgentRunner manifest、run context/result、resource handles 和 pull/callback API。 +- 提供 `AgentRunAPIProxy` 与 SDK-owned scoped MCP bridge。 +- 保持协议传输与权限校验可测试,不把 Host 私有 Query 对象暴露给插件。 -**目标**:在 langbot-plugin-sdk 中定义新的事件体系、通用实体、API 接口和适配器基类。 +### 验收 -**仓库**:`langbot-plugin-sdk` +- stdio/WebSocket runtime 对 typed action 的序列化一致。 +- 无 `run_id` 或越权资源调用被拒绝。 +- Python proxy 与 MCP bridge 对同一 Host tool 呈现一致结果/错误形状。 -### 2.1 任务清单 +## 4. P1:平台适配器 -| # | 任务 | 文件/模块 | 说明 | -|---|------|----------|------| -| 1.1 | 定义通用事件基类层次 | `api/entities/builtin/platform/events.py` | 新增 `MessageReceivedEvent`, `MessageEditedEvent`, `GroupMemberJoinedEvent` 等,保留现有 `FriendMessage`/`GroupMessage` | -| 1.2 | 定义平台特有事件基类 | `api/entities/builtin/platform/events.py` | 新增 `PlatformSpecificEvent` | -| 1.3 | 扩展通用实体 | `api/entities/builtin/platform/entities.py` | 新增 `User`(统一 Friend/GroupMember 的基础)、`Channel` 等,保留现有实体 | -| 1.4 | 清理消息组件 | `api/entities/builtin/platform/message.py` | 将 `WeChatMiniPrograms` 等 WeChat 特有组件标记为 platform-specific,不再作为通用组件 | -| 1.5 | 定义新适配器基类 | `api/definition/abstract/platform/adapter.py` | 新增 `AbstractPlatformAdapter`(继承现有 `AbstractMessagePlatformAdapter` 并扩展通用 API 方法),保留旧基类 | -| 1.6 | 定义 API 能力声明 | `api/definition/abstract/platform/capabilities.py`(新文件) | `AdapterCapabilities` 数据类,声明适配器支持的事件和 API | -| 1.7 | 定义 `NotSupportedError` | `api/entities/builtin/platform/errors.py`(新文件) | 可选 API 未实现时抛出的异常 | +每个适配器按自己的能力增量接入,而不是要求所有平台一次性支持全部事件。 -### 2.2 关键设计约束 +### 单适配器步骤 -- 所有新增定义以**新增文件或新增类**的方式引入,**不修改**现有类的字段和方法签名 -- 现有 `AbstractMessagePlatformAdapter` 保留不动,新基类 `AbstractPlatformAdapter` 继承它 -- 新事件类与旧事件类并存,通过 `event_type` 字段(命名空间字符串)区分 +1. 将原生 SDK callback 转换为规范化事件。 +2. 声明实际支持的事件与 API,不把缺失能力伪装为成功。 +3. 实现 send/reply/edit/delete/group/user 等适用 API 与平台透传。 +4. 对消息链、媒体、reply target 和错误语义写单元测试。 +5. 按 `adapters/acceptance-checklist.md` 记录真实平台 probe。 -### 2.3 验收标准 +### 验收 -- [ ] 所有新增类可正常 import 且通过类型检查 -- [ ] 现有 `FriendMessage`, `GroupMessage`, `AbstractMessagePlatformAdapter` 等类行为不变 -- [ ] 新增单元测试覆盖事件序列化/反序列化、实体构造 -- [ ] SDK 版本号 minor bump(如 `0.x.0` → `0.x+1.0`) +- `message.received` 保持正常收发。 +- 新事件不会重复转换或产生循环合成事件。 +- `supported_apis` 与实际调用能力一致。 ---- +## 5. P2:Host 路由 -## 3. Phase 2:适配器重构 +### 执行顺序 -**目标**:将现有单文件适配器迁移到独立目录结构,实现新事件监听和通用 API。 - -**仓库**:`LangBot`(后端) - -### 3.1 适配器迁移优先级 - -根据用户量和代表性,建议按以下顺序迁移: - -| 优先级 | 适配器 | 理由 | -|--------|--------|------| -| P0 | **Telegram** | 用户量大,API 最完善,适合作为参考实现 | -| P0 | **Discord** | 国际用户主要平台,事件类型丰富 | -| P1 | **aiocqhttp**(OneBot v11) | 国内 QQ 用户主要适配器 | -| P1 | **Satori** | 通用协议适配器,覆盖多个平台 | -| P2 | **Lark** / **DingTalk** / **Slack** | 企业平台,用户量中等 | -| P2 | **qqofficial** / **WeChat 系列** | 国内用户 | -| P3 | **Kook** / **LINE** / **WeCom 系列** | 用户量较小 | -| P3 | **WebSocket** | 内置适配器,相对简单 | -| P4 | **legacy/*** | 遗留适配器,按需决定是否迁移或废弃 | - -### 3.2 单个适配器迁移步骤(以 Telegram 为例) - -| # | 任务 | 说明 | -|---|------|------| -| 2.1 | 创建目录结构 | `pkg/platform/adapters/telegram/` 下创建 `__init__.py`, `adapter.py`, `event_converter.py`, `message_converter.py`, `api_impl.py`, `types.py`, `manifest.yaml` | -| 2.2 | 迁移消息转换器 | 将 `TelegramMessageConverter` 从 `sources/telegram.py` 搬到 `adapters/telegram/message_converter.py`,逻辑不变 | -| 2.3 | 重写事件转换器 | 新的 `TelegramEventConverter` 支持将 Telegram Update 转换为所有通用事件类型(不只是消息),不支持的事件转为 `PlatformSpecificEvent` | -| 2.4 | 实现通用 API | 在 `api_impl.py` 中实现 `edit_message`, `delete_message`, `get_group_info` 等 Telegram 支持的通用 API | -| 2.5 | 实现透传 API | 在 `adapter.py` 中实现 `call_platform_api`,将 action 映射到 Telegram Bot API 调用 | -| 2.6 | 声明能力 | 在 `manifest.yaml` 或适配器类中声明支持的事件和 API 列表 | -| 2.7 | 新建 Adapter 主类 | `TelegramAdapter` 继承 `AbstractPlatformAdapter`(新基类),委托各模块实现 | -| 2.8 | 更新 manifest.yaml | 更新 `execution.python.path` 指向新位置 | -| 2.9 | 验证 | 确保新适配器通过现有消息收发流程的测试 | - -### 3.3 基础设施任务 - -| # | 任务 | 说明 | -|---|------|------| -| 2.A | 创建 `adapters/_base/` | 将 SDK 中新基类的运行时辅助代码放在此处(如事件分发辅助函数) | -| 2.B | 更新 ComponentDiscovery | 使 `discover_blueprint` 支持扫描 `adapters/` 子目录中的 YAML | -| 2.C | 更新 `templates/components.yaml` | 将 `fromDirs` 从 `pkg/platform/sources/` 改为 `pkg/platform/adapters/`(过渡期两个都扫描) | -| 2.D | 保留旧 sources/ | 过渡期不删除旧文件,通过 manifest 的 `deprecated: true` 标记 | - -### 3.4 验收标准 - -- [ ] 已迁移的适配器在新目录结构下正常启动和收发消息 -- [ ] 新事件(如 `message.edited`)在支持的平台上正确触发 -- [ ] 通用 API(如 `edit_message`)在支持的平台上正确执行 -- [ ] 未迁移的适配器(仍在 `sources/`)继续正常工作 -- [ ] ComponentDiscovery 同时扫描新旧目录 - ---- - -## 4. Phase 3:核心系统 - -**目标**:实现 EventBus、EventRouter 和事件处理器框架,将事件从适配器分发到不同的处理器。 - -**仓库**:`LangBot`(后端) - -### 4.1 任务清单 - -| # | 任务 | 文件/模块 | 说明 | -|---|------|----------|------| -| 3.1 | 实现 EventBus | `pkg/platform/event_bus.py`(新文件) | 事件总线:接收适配器事件,进行日志记录,分发给 EventRouter | -| 3.2 | 实现 EventRouter | `pkg/platform/event_router.py`(新文件) | 事件路由引擎:读取 Bot 的 `event_handlers` 配置,匹配事件类型,分发到对应 Handler | -| 3.3 | 实现 PipelineHandler | `pkg/platform/handlers/pipeline_handler.py` | 将 `message.received` 事件转为现有 Query,进入 Pipeline 流水线 | -| 3.4 | 实现 AgentHandler | `pkg/platform/handlers/agent_handler.py` | 直接调用 RequestRunner 处理事件,不经过 Pipeline 多 Stage 流程 | -| 3.5 | 实现 WebhookHandler | `pkg/platform/handlers/webhook_handler.py` | 将事件 POST 到外部 URL,解析响应执行动作(重构现有 WebhookPusher) | -| 3.6 | 实现 PluginHandler | `pkg/platform/handlers/plugin_handler.py` | 将事件分发给插件 EventListener(复用现有 plugin_connector 机制) | -| 3.7 | Bot 实体扩展 | `pkg/entity/persistence/bot.py` | 新增 `event_handlers` JSON 字段 | -| 3.8 | 数据库迁移 | `pkg/persistence/migrations/` | 新增迁移脚本:添加 `event_handlers` 列,将现有 `use_pipeline_uuid` 数据迁移为 `event_handlers` 格式 | -| 3.9 | 重构 RuntimeBot | `pkg/platform/botmgr.py` | 将 `initialize()` 中硬编码的 `on_friend_message`/`on_group_message` 回调替换为通过 EventBus 分发所有事件 | -| 3.10 | 重构 MessageAggregator | `pkg/pipeline/aggregator.py` | 从 RuntimeBot 解耦,作为 PipelineHandler 的内部机制(只对 `message.received` 事件生效) | -| 3.11 | Agent Handler 中 RequestRunner 解耦 | `pkg/provider/runner.py` + handlers | RequestRunner 需要能独立于 Pipeline Stage 运行,为 Agent Handler 提供轻量调用路径 | -| 3.12 | HTTP API 扩展 | `pkg/api/http/controller/` | 新增/更新 Bot API 端点以支持 `event_handlers` 的 CRUD | - -### 4.2 数据迁移策略 - -现有 Bot 表有 `use_pipeline_uuid` 字段,需要自动迁移为 `event_handlers`: - -该步骤只改写 Bot 的路由表示,仍通过原 UUID 指向原 Pipeline;不得创建独立 Agent,也不得复制 Pipeline 内嵌的 runner 配置。用户需要 Agent 时自行新增并绑定。 - -```python -# 迁移逻辑伪代码 -for bot in all_bots: - if bot.use_pipeline_uuid: - bot.event_handlers = [ - { - "event_type": "message.received", - "handler_type": "pipeline", - "handler_config": { - "pipeline_uuid": bot.use_pipeline_uuid - } - } - ] - else: - bot.event_handlers = [] +```text +adapter event + -> EventBus observer broadcast + -> EventRouter match event_bindings + -> one target: Pipeline | Agent | discard + -> Host delivery ``` -### 4.3 RuntimeBot 重构要点 +### 约束 -当前 `RuntimeBot.initialize()` 硬编码注册两个回调: +- Plugin EventListener 是 observer,不作为 priority fallback。 +- Pipeline 只处理消息事件并复用完整 Stage 链。 +- Agent 使用独立 Agent 配置和 AgentRunner Host orchestrator。 +- edit/reaction 等事件的 observer 副作用能力按事件和 adapter 能力过滤。 +- dry-run 与合成派发必须使用同一匹配器,避免 UI 预览与真实路由漂移。 -```python -# 现有代码 (botmgr.py) -self.adapter.register_listener(FriendMessage, on_friend_message) -self.adapter.register_listener(GroupMessage, on_group_message) -``` +### 验收 -重构后改为注册通用事件回调: +- 精确、namespace wildcard、全局 wildcard、filters 与 priority 有单元覆盖。 +- 同一事件最多一个响应目标,但 observer 仍能收到事件。 +- Pipeline 与 Agent 可以在同一个 Bot 的不同 binding 中同时生效。 -```python -# 新代码 -async def on_event(event: Event, adapter: AbstractPlatformAdapter): - await self.event_bus.emit( - bot_uuid=self.bot_entity.uuid, - event=event, - adapter=adapter, - ) +## 6. P3:独立 Agent 与 AgentRunner -# 注册所有事件类型的统一回调 -self.adapter.register_listener(Event, on_event) -``` +### 工作项 -EventBus 接收事件后,调用 EventRouter 按配置分发。 +- `agents` 只保存 Agent;Pipeline 继续使用自己的表和 API。 +- Agent config 使用 `runner.id` 与 `runner_config[runner_id]`。 +- registry 只展示已安装、有效的插件 AgentRunner。 +- Host 构造 run-scoped resources、state、delivery 与 event log/transcript。 +- SDK/Python `call_tool` 和 scoped MCP bridge 都回到同一个 Host ToolManager。 +- Box session 由 Host 将 instance/workspace/bot/adapter/target/thread scope 规范化并哈希为固定长度 `lb-box-`;同 scope 稳定、不同 scope 隔离、缺少 identity 时 fail closed。 -### 4.4 事件处理器执行流程 +### 验收 -``` -EventBus.emit(bot_uuid, event, adapter) - │ - ▼ -EventRouter.route(bot_uuid, event) - │ 查询 bot.event_handlers 配置 - │ 匹配 event_type(精确匹配 > 通配符 *) - ▼ -匹配到的 Handler(s) - │ - ├── PipelineHandler.handle(event, adapter) - │ │ 仅支持 message.received - │ │ 构造 Query → MessageAggregator → QueryPool → Pipeline - │ └── 沿用现有完整流水线机制 - │ - ├── AgentHandler.handle(event, adapter) - │ │ 根据 handler_config 选择 RequestRunner - │ │ 直接调用 runner.run() 处理事件 - │ └── 将结果通过 adapter API 回复 - │ - ├── WebhookHandler.handle(event, adapter) - │ │ 序列化事件为 JSON - │ │ POST 到 handler_config.url - │ └── 解析响应,执行动作(回复消息、调用 API 等) - │ - └── PluginHandler.handle(event, adapter) - │ 通过 plugin_connector 分发给插件 - └── 插件 EventListener 处理 -``` +- 安装/卸载 Runner 后 metadata 与表单选项同步。 +- Runner 无法调用未授权 model/tool/knowledge/state。 +- 两种 Host callback transport 不能覆盖 sandbox session id。 +- LocalAgent、ACP/ClaudeCode/Codex 与外部服务 Runner 不需要 Host id 特判。 -### 4.5 验收标准 +## 7. P4:WebUI -- [ ] `message.received` 事件通过 PipelineHandler 正确进入现有 Pipeline(与旧行为一致) -- [ ] 新增事件(如 `group.member_joined`)能通过 PluginHandler 分发给插件 -- [ ] AgentHandler 能直接调用 RequestRunner(至少 `local-agent`)处理事件并回复 -- [ ] WebhookHandler 能将事件 POST 到外部 URL -- [ ] 数据库迁移正确执行,`use_pipeline_uuid` 数据迁移到 `event_handlers` -- [ ] 现有 Bot 在不修改配置的情况下行为不变(自动迁移保证) +### 工作项 ---- +- `/home/agents` 聚合显示 Agent 与 Pipeline,并明确类型。 +- 创建时选择 Agent 或 Pipeline,编辑时进入各自表单。 +- Agent 表单读取动态 Runner metadata,保存当前 `runner` / `runner_config` 形状。 +- Bot 事件编排器编辑 `event_pattern`、target、filters、priority 与 enabled。 +- 非消息事件过滤 Pipeline;Agent 按声明事件能力过滤。 -## 5. Phase 4:插件 SDK 集成 +### 验收 -**目标**:将新事件和 API 通过插件 SDK 暴露给插件开发者,同时实现兼容层。 +- 页面不出现 LocalAgent 专属 banner、变量隐藏或 Box/Pipeline 注入逻辑。 +- 空 Runner 市场状态给出可安装 AgentRunner 的正常路径。 +- Pipeline Debug Chat/Monitoring 与 Agent 运行日志分别可用。 -**仓库**:`langbot-plugin-sdk` + `LangBot` +## 8. P5:发布门禁 -### 5.1 任务清单 +### 自动化 -| # | 任务 | 说明 | -|---|------|------| -| 4.1 | 新增插件事件包装 | 在 `api/entities/events.py` 中为每个通用事件新增插件级事件类(如 `MessageEditedReceived`, `MemberJoinedReceived`) | -| 4.2 | 兼容层实现 | `PersonMessageReceived` / `GroupMessageReceived` 由新的 `MessageReceivedEvent` 自动生成,旧事件作为新事件的 alias | -| 4.3 | 新 API 暴露 | 在 `LangBotAPIProxy` 中新增方法:`edit_message`, `delete_message`, `get_group_info`, `get_user_info`, `call_platform_api` 等 | -| 4.4 | 通信协议扩展 | 在 `entities/io/actions/enums.py` 中新增 action 枚举(如 `EDIT_MESSAGE`, `DELETE_MESSAGE`, `GET_GROUP_INFO`, `CALL_PLATFORM_API`) | -| 4.5 | Runtime Handler 扩展 | 在 PluginConnectionHandler / ControlConnectionHandler 中添加新 action 的处理逻辑 | -| 4.6 | EventListener 扩展 | 确保 `@handler()` 装饰器支持注册新事件类型 | -| 4.7 | QueryBasedAPI 扩展 | 在 `QueryBasedAPIProxy` 中新增事件上下文相关的 API(如 `get_event_source_adapter`) | +- LangBot backend unit/integration tests 与 Ruff。 +- SDK AgentRunner/proxy/MCP bridge tests。 +- Web lint/build 与关键 Playwright cases。 +- `skills/bin/lbs validate`、`skills/bin/lbs index --check`。 +- LocalAgent 与其他官方 Runner plugin package/test gate。 -### 5.2 兼容层详细设计 +### 真实环境 -``` -新事件系统 旧事件系统(兼容层) -───────────── ───────────────── -MessageReceivedEvent ┌→ PersonMessageReceived (chat_type == "private") - (chat_type: "private"|"group") ┤ - └→ GroupMessageReceived (chat_type == "group") -``` +- 至少一个消息事件走 Pipeline。 +- 至少一个非消息事件走独立 Agent 并执行平台动作。 +- SDK/Python 和 MCP bridge 各完成一次受权工具调用,并证明二者都进入同一个 `PluginToRuntimeAction.CALL_TOOL` Host handler。 +- Box 可用/不可用的降级路径可观察且无 Runner 特判。 -**实现方式**:在 RuntimeEventDispatcher 中,当分发 `MessageReceivedEvent` 给插件时,同时生成对应的旧事件类实例。插件可以用新事件类或旧事件类注册 handler,都能收到。 +## 9. 非目标 -### 5.3 验收标准 - -- [ ] 现有插件(使用旧事件和 API)无需修改即可运行 -- [ ] 新插件可以使用新事件类型(如 `MemberJoinedReceived`)注册 handler -- [ ] 新 API(如 `edit_message`)可通过 `self.edit_message()` 或 `event_context.edit_message()` 调用 -- [ ] 透传 API `call_platform_api` 可正常调用适配器特有功能 -- [ ] 所有新 action 的通信协议正确工作(stdio / WebSocket) - ---- - -## 6. Phase 5:WebUI 编排面板 - -**目标**:在 WebUI 的 Bot 管理页面实现事件处理器的可视化编排。 - -**仓库**:`LangBot`(前端 `web/`) - -### 6.1 任务清单 - -| # | 任务 | 说明 | -|---|------|------| -| 5.1 | Bot 编辑页面扩展 | 在 Bot 编辑页面新增「事件处理」面板 | -| 5.2 | 事件处理器列表组件 | 可视化展示当前 Bot 的 `event_handlers` 列表,支持增删改排序 | -| 5.3 | 事件类型选择器 | 下拉选择事件类型(命名空间分组展示),支持通配符 `*` | -| 5.4 | Handler 类型选择与配置 | 选择 handler 类型后展示对应的配置表单(Pipeline 选择器、Runner 选择器、Webhook URL 等) | -| 5.5 | Pipeline Handler 配置 | 复用现有的 Pipeline 选择 UI(从现有 `use_pipeline_uuid` 选择器迁移) | -| 5.6 | Agent Handler 配置 | Runner 选择器(local-agent / dify / n8n / coze 等)+ Runner 参数配置表单 | -| 5.7 | Webhook Handler 配置 | URL 输入、认证方式选择、Header 配置 | -| 5.8 | Plugin Handler 配置 | 通常无需额外配置,分发给所有匹配的插件 EventListener | -| 5.9 | HTTP API 对接 | 前端调用后端 API 保存/读取 `event_handlers` 配置 | -| 5.10 | 迁移提示 | 对于从旧版本升级的用户,如果检测到 `use_pipeline_uuid` 已自动迁移,展示提示说明 | - -### 6.2 UI 交互设计概要 - -``` -┌─ Bot 编辑页面 ─────────────────────────────────────┐ -│ │ -│ 基本信息 │ 适配器配置 │ ★ 事件处理 │ │ -│ │ -│ ┌─ 事件处理器列表 ────────────────────────────┐ │ -│ │ │ │ -│ │ ① message.received → Pipeline: "主流水线" │ │ -│ │ [编辑] [删除] │ │ -│ │ │ │ -│ │ ② group.member_joined → Agent: local-agent │ │ -│ │ [编辑] [删除] │ │ -│ │ │ │ -│ │ ③ * (默认) → Plugin │ │ -│ │ [编辑] [删除] │ │ -│ │ │ │ -│ │ [+ 添加事件处理器] │ │ -│ │ │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ [保存] [取消] │ -└─────────────────────────────────────────────────────┘ -``` - -### 6.3 验收标准 - -- [ ] 用户可以在 WebUI 上为 Bot 添加/编辑/删除事件处理器 -- [ ] 四种 Handler 类型均有对应的配置表单 -- [ ] 配置保存后正确写入数据库 `event_handlers` 字段 -- [ ] 旧版本升级后,自动迁移的配置在 UI 上正确展示 -- [ ] Pipeline Handler 的行为与旧的 `use_pipeline_uuid` 完全一致 - ---- - -## 7. Phase 6:文档与示例 - -**目标**:更新所有面向开发者的文档和示例。 - -**仓库**:`langbot-wiki`, `langbot-plugin-demo` - -### 7.1 任务清单 - -| # | 任务 | 仓库 | 说明 | -|---|------|------|------| -| 6.1 | EBA 架构概览文档 | langbot-wiki | 面向用户的新架构说明 | -| 6.2 | 适配器开发指南更新 | langbot-wiki | 如何开发一个新的适配器(新目录结构、新基类、事件转换等) | -| 6.3 | 插件开发指南更新 | langbot-wiki | 新事件类型、新 API 的使用说明 | -| 6.4 | 插件迁移指南 | langbot-wiki | 现有插件如何迁移到新事件/API(如果需要使用新能力) | -| 6.5 | 事件处理器配置指南 | langbot-wiki | WebUI 上如何配置事件处理器 | -| 6.6 | 示例插件更新 | langbot-plugin-demo | HelloPlugin 增加新事件监听示例、新 API 调用示例 | -| 6.7 | 新示例插件 | langbot-plugin-demo | 新建一个示例展示非消息事件处理(如入群欢迎) | - ---- - -## 8. 风险评估与缓解 - -### 8.1 技术风险 - -| 风险 | 影响 | 概率 | 缓解措施 | -|------|------|------|----------| -| 适配器迁移中断现有功能 | 高 | 中 | 新旧目录并存,ComponentDiscovery 同时扫描两个目录,逐个适配器迁移验证 | -| 事件模型不兼容导致插件崩溃 | 高 | 低 | 兼容层保证旧事件类型继续工作,新增类不修改旧类 | -| 数据库迁移失败 | 高 | 低 | 迁移脚本做前置校验,`use_pipeline_uuid` 在过渡期保留不删除 | -| RequestRunner 解耦破坏 Pipeline | 高 | 中 | Agent Handler 调用 Runner 的路径独立于 Pipeline,不修改现有 Pipeline Stage 中的 Runner 调用逻辑 | -| 性能回退(EventBus 额外开销) | 中 | 低 | EventBus 在进程内同步分发,无额外序列化/网络开销 | -| 各平台事件差异大难以统一 | 中 | 中 | 通用事件只抽象最大公约数字段,差异部分保留在 `source_platform_object`;不支持的事件走 `PlatformSpecificEvent` | - -### 8.2 兼容性风险 - -| 风险 | 缓解措施 | -|------|----------| -| 现有插件使用旧事件类 | 兼容层自动将新事件转为旧事件分发,两种事件类都能注册 handler | -| 现有插件调用 `reply()` / `send_message()` | 这两个 API 保持不变,只是底层实现可能微调 | -| 第三方基于 `AbstractMessagePlatformAdapter` 开发的适配器 | 旧基类保留,新基类继承旧基类,第三方适配器无需立即迁移 | -| 用户自定义 Pipeline 配置 | Pipeline 机制完整保留,PipelineHandler 只是入口变了(从 RuntimeBot 硬编码变为 EventRouter 配置) | - -### 8.3 回滚策略 - -每个 Phase 独立可回滚: - -- **Phase 1**(SDK 新增类):删除新增文件,回退 SDK 版本号 -- **Phase 2**(适配器目录):恢复 `components.yaml` 的 `fromDirs` 指向旧目录,旧 sources/ 未删除 -- **Phase 3**(核心系统):回退数据库迁移,恢复 RuntimeBot 旧的硬编码回调 -- **Phase 4**(插件集成):回退 SDK 版本,插件使用旧版 SDK -- **Phase 5**(WebUI):前端回退,Bot 编辑页面隐藏事件处理面板 - ---- - -## 9. 里程碑与时间线建议 - -| 里程碑 | 阶段 | 预期产出 | -|--------|------|----------| -| M1 | Phase 1 完成 | SDK 新版本发布,包含新事件/实体/基类定义 | -| M2 | Phase 2 首批适配器(Telegram + Discord) | 两个参考实现,验证目录结构和事件/API 体系 | -| M3 | Phase 3 核心系统 | EventBus + EventRouter + 四种 Handler 可用 | -| M4 | Phase 2 剩余适配器 | 所有活跃适配器迁移完成 | -| M5 | Phase 4 插件集成 | 新 SDK 发布,插件可使用新事件和 API | -| M6 | Phase 5 WebUI | 事件处理器编排面板上线 | -| M7 | Phase 6 文档 | 开发者文档和示例更新完毕 | - -建议 M1-M3 作为第一个大版本发布(如 v5.0),M4-M7 在后续小版本迭代中完成。 - ---- - -## 10. 开发指引 - -### 10.1 分支策略 - -建议在主仓库创建 `feature/eba` 长期特性分支,各 Phase 在子分支上开发后合入特性分支: - -``` -main - └── feature/eba - ├── feature/eba-sdk-entities (Phase 1) - ├── feature/eba-adapter-telegram (Phase 2) - ├── feature/eba-adapter-discord (Phase 2) - ├── feature/eba-core-system (Phase 3) - ├── feature/eba-plugin-sdk (Phase 4) - └── feature/eba-webui (Phase 5) -``` - -### 10.2 测试策略 - -| 层次 | 测试内容 | 工具 | -|------|----------|------| -| 单元测试 | 事件序列化/反序列化、实体构造、API 调用 mock | pytest | -| 集成测试 | EventBus → EventRouter → Handler 全链路 | pytest + asyncio | -| 适配器测试 | 各适配器的事件转换、消息转换、API 调用 | pytest + mock SDK | -| 端到端测试 | 从模拟平台事件到完整处理流程 | staging 环境 | -| 插件兼容性测试 | 旧插件在新系统下的行为 | langbot-plugin-demo | - -### 10.3 代码审查关注点 - -- 新增代码是否影响现有行为 -- 兼容层是否正确映射所有旧事件/API 场景 -- 数据库迁移是否可逆 -- 新 API 的错误处理(`NotSupportedError`)是否一致 -- 事件模型的序列化在 stdio/WebSocket 通信中是否正确 +- 不把 Pipeline 改名或包装成 Agent。 +- 不自动把 Pipeline 内 runner 配置迁移成 Agent。 +- 不为 3.x 保留数据库迁移、旧模板或配置 fallback。 +- 不要求所有 Runner 经 MCP;本地 Python Runner 可以直接使用 SDK。 +- 不允许 Runner 自定义 Box session scope。 +- 不在首版实现多 Agent 串并联;多步骤编排留给后续 workflow。 diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md index 2a5e06e65..904e77715 100644 --- a/docs/review/box-architecture.md +++ b/docs/review/box-architecture.md @@ -1,6 +1,6 @@ # Box 系统架构深度分析 -> 更新日期: 2026-06-02 +> 更新日期: 2026-07-12 > 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) > 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md) @@ -13,7 +13,9 @@ ┌──────────────────────────────────────────────────────────────────┐ │ LangBot 主进程 │ │ │ -│ LocalAgentRunner ──> ToolManager ──> NativeToolLoader │ +│ AgentRunner ──> SDK call_tool / scoped MCP bridge │ +│ │ │ │ +│ └────────────────> ToolManager ──> NativeToolLoader │ │ │ │ │ │ │ │ │ exec / read / write / edit │ │ │ │ glob / grep │ @@ -32,7 +34,7 @@ │ ├─ Host mount 校验 (allowed_mount_roots) │ │ ├─ Workspace quota 检查 │ │ ├─ 输出截断 (head+tail) │ -│ ├─ Session ID 模板解析 (resolve_box_session_id) │ +│ ├─ Host scope 哈希 (resolve_box_session_id) │ │ ├─ 技能挂载组装 (build_skill_extra_mounts) │ │ ├─ 重连循环 (_reconnect_loop, 指数退避) │ │ └─ BoxRuntimeConnector │ @@ -85,7 +87,8 @@ **核心设计原则**: - Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller) - 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process -- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md)) +- AgentRunner 无权指定 session scope。SDK/Python `call_tool` 与 scoped MCP bridge 都发出同一个 `PluginToRuntimeAction.CALL_TOOL`,最终由 Host 的 ToolManager 执行,并使用当前 run 保存的同一个 execution Query +- Box 内托管的 stdio MCP server 使用独立的长期 `mcp-shared` session;它不是 AgentRunner 本次事件的 sandbox session(详见 [box-session-scope.md](./box-session-scope.md)) --- @@ -93,7 +96,7 @@ ### 2.1 BoxService (`pkg/box/service.py`, 722 行) -应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板: +应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Host scope 哈希: 主要公开方法(按定义顺序): @@ -104,7 +107,7 @@ BoxService ├─ _reconnect_loop(connector) 指数退避重连 ├─ available (property) 连接状态 │ - ├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id + ├─ resolve_box_session_id(query) 哈希 Host 私有 scope,生成固定长度 session_id ├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 │ ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 @@ -137,6 +140,8 @@ BoxService **输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。 +**Session 所有权**: `resolve_box_session_id(query)` 只接受 Host 已确定的私有 scope 或 Query launcher/session identity,并输出 `lb-box-` + 64 位小写 SHA-256 十六进制摘要(固定 71 个 ASCII 字符)。哈希输入是 canonical JSON,包含 instance、workspace、bot、platform adapter、target type/id 与 thread;原始用户、群组、conversation 或 event id 不会出现在 Box session id 中。相同 Host scope 稳定复用,不同 target/thread/workspace/bot/adapter/instance 相互隔离;缺少可用 identity 时 fail closed。Pipeline、Agent 或 AgentRunner 配置都不能覆盖该规则。 + **Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 ### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) @@ -414,7 +419,9 @@ ToolManager.initialize() 1. 验证 skill 已激活 2. 单次 exec 只能引用一个 skill 包 3. 若 skill 是 Python 项目(有 `requirements.txt` 或 `pyproject.toml`),命令会被 venv bootstrap 包裹(在 skill 挂载点内创建 `.venv`) -4. 调用 `box_service.execute_tool()` → 走默认 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** +4. 调用 `box_service.execute_tool()` → 走 Host 从当前事件生成的 session_id 与已组装好的 `extra_mounts`,**不再为每 skill 起独立 session** + +AgentRunner 可以直接通过 SDK/Python `AgentRunAPIProxy.call_tool` 调用这些工具,也可以让外部 harness 通过 SDK-owned scoped MCP bridge 回调。两条入口都发送 `PluginToRuntimeAction.CALL_TOOL`,共享同一个 run authorization、Host session 中保存的 execution Query、ToolManager 与 `resolve_box_session_id(query)` 规则;Runner 不能提交自定义 Box session id。Pipeline run 保存原 Query;纯 EBA run 由 Host 构造 `pipeline_config=None`、`pipeline_uuid=None` 的最小 Query。 ### 4.3 MCP-in-Box (`mcp_stdio.py`, 354 行) @@ -422,7 +429,7 @@ ToolManager.initialize() ``` initialize() - 1. 复用/创建共享 session (session_id = _build_box_session_id()) + 1. 复用/创建共享 session (`session_id = mcp-shared`) - persistent=True,长期保持 2. workspace.execute_raw(install_cmd) 安装依赖 (可选) 3. 将每个 MCP server 文件 stage 到 /workspace/.mcp// @@ -435,6 +442,8 @@ initialize() 每条 MCP server 是同一 session 中的一个 managed process,独立的 `process_id`、独立 attach URL,互不阻塞。 +这里的 `mcp-shared` 只承载 LangBot 管理的 stdio MCP server 进程。AgentRunner 的 scoped MCP bridge 是回调 Host 工具的协议入口,不会把事件运行的 exec/read/write 改到 `mcp-shared`。 + --- ## 5. 启动与生命周期 @@ -566,22 +575,14 @@ volumes: | http/sse MCP server | 正常 | 正常(不依赖 Box) | | Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback | | Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) | -| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 | | Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | | 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | > 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。 -### Pipeline 配置 (templates/metadata/pipeline/ai.yaml) +### Session scope -`local-agent.config.box-session-id-template` 控制 session 作用域,预设: - -- `{launcher_type}_{launcher_id}` — 每个会话 (推荐,默认) -- `{launcher_type}_{launcher_id}_{sender_id}` — 群聊每个用户 -- `{launcher_type}_{launcher_id}_{conversation_id}` — 每个对话上下文 -- `{query_id}` — 每条消息(完全隔离) - -详见 [box-session-scope.md](./box-session-scope.md)。 +Pipeline 与 AgentRunner 配置不再暴露 sandbox session 模板。Host 将当前平台会话/事件 scope 规范化后哈希成固定长度的 `lb-box-`;相同 scope 稳定复用,不同 scope 隔离,缺少 identity 时拒绝执行。SDK/Python 与 scoped MCP bridge 的工具调用遵守同一规则。详见 [box-session-scope.md](./box-session-scope.md)。 ### REST API diff --git a/docs/review/box-session-scope.md b/docs/review/box-session-scope.md index bb92265de..b75d2518a 100644 --- a/docs/review/box-session-scope.md +++ b/docs/review/box-session-scope.md @@ -1,402 +1,181 @@ -# Box Session Scope Design +# Box Session Scope -> Date: 2026-04-18 (last reviewed 2026-06-02) -> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md). -> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk) +> Last reviewed: 2026-07-12 +> Status: implemented Host-owned, hashed execution scope; Runner/Pipeline session templates are removed. > Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md) ---- +## 1. Decision -## 0. Implementation Status (2026-05-19) +The LangBot Host owns the Box session used by an event run. A Pipeline, Agent, +or AgentRunner cannot choose a global, per-user, per-conversation, or per-query +sandbox mode. -This document was authored as a design proposal. The current `feat/sandbox` branch -has shipped the design largely as written: +`BoxService.resolve_box_session_id(query)` always returns this shape: -| Item | Status | Notes | -|------|--------|-------| -| `BoxMountSpec` + `BoxSpec.extra_mounts` | ✅ Shipped | SDK `box/models.py` | -| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) | -| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` | -| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` | -| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` | -| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch | -| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` | -| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` | -| Per-tenant / quota integration with templates | ❌ Not started | See [box-tob-analysis.md](./box-tob-analysis.md) | - -The "Phase 2 deferred" note in §10 is **out of date** — MCP unification went in on -the same line. Pipeline-scoped (not user-scoped) MCP container is the realized -behavior: each pipeline's MCP servers share one `mcp-` session, and -user exec sessions use the template-derived id. - -The remaining open work is multi-tenant overlays (tenant_id in session_id, -quota counters keyed by tenant), tracked in the toB analysis doc rather than here. - ---- - -## 1. Problems - -### 1.1 Default exec: per-message containers - -Currently, `BoxService.execute_tool()` sets `session_id = str(query.query_id)` — an -auto-incrementing integer per incoming message. Every user message creates a new sandbox -container. Dependencies installed and in-container state are lost between messages. - -### 1.2 Three isolated container pools - -Default exec, skills, and MCP servers each manage their own containers with -independent session IDs: - -| Path | Session ID | Container | -|--------------|-----------------------------------------------|-------------| -| Default exec | `str(query_id)` (per message) | Ephemeral | -| Skill exec | `skill-{launcher}_{id}-{skill_name}` | Per skill | -| MCP stdio | `mcp-{server_uuid}` | Per server | - -This means a single logical user interaction can spawn 3+ containers that cannot -share state, see each other's files, or reuse installed dependencies. - -### 1.3 Single bind mount limitation - -`BoxSpec` currently supports only **one** `host_path` → `mount_path` bind mount. -This prevents mounting both a default workspace and skill directories into the -same container. - ---- - -## 2. Concept Model - -``` -Platform Message - → Query (query_id: int, auto-increment, per message) - → Session (launcher_type + launcher_id, per chat window) - → Conversation (uuid, per dialogue context within a Session) +```text +lb-box-<64 lowercase SHA-256 hex characters> ``` -| Concept | Key | Example | Scope | -|---------------|-------------------------------------|----------------------------|------------------------------| -| Query | `query_id` | `42` | Single message | -| Session | `launcher_type` + `launcher_id` | `group_123456` | Chat window (group or PM) | -| Conversation | `conversation_id` (UUID) | `a1b2c3d4-...` | Dialogue context within a Session | -| Sender | `sender_id` | `789` | Individual user | +The result is exactly 71 ASCII characters. Raw platform, user, group, +conversation, thread, and event identifiers never appear in the Box session +id. This avoids unsafe path characters, unbounded identifier length, and +identity leakage through runtime/container metadata. -Note: in a **group chat**, all users share the same Session (keyed by `group_id`). The -individual sender is tracked as `sender_id` but does not affect Session/Conversation routing. +This rule replaces all former concepts of: ---- +- Pipeline or Runner `box-session-id-template` fields; +- a global forced session template; +- API fields that let a caller supply sandbox scope; +- LocalAgent-specific Host injection of Box availability, scope, or Pipeline id. -## 3. Target Scenarios +## 2. Canonical Host scope -| # | Scenario | Box Granularity | Desired `session_id` | -|----|--------------------------------|------------------------------------------|---------------------------------------------------------| -| 1 | Personal assistant | 1 Box per user, long-lived | `{launcher_type}_{launcher_id}` | -| 2 | Customer service | 1 Box per customer, cross-pipeline | `{launcher_type}_{launcher_id}` | -| 3 | Internal employee tool | 1 Box per employee | `{launcher_type}_{launcher_id}` | -| 4 | Group chat shared assistant | 1 Box per group | `{launcher_type}_{launcher_id}` | -| 5 | Group chat isolated per user | 1 Box per user within a group | `{launcher_type}_{launcher_id}_{sender_id}` | -| 6 | Teaching (cross-channel) | 1 Box per student across groups/PMs | `{sender_id}` | -| 7 | One-off execution | 1 Box per message (current behavior) | `{query_id}` | -| 8 | Multi-project development | 1 Box per conversation context | `{launcher_type}_{launcher_id}_{conversation_id}` | +Before hashing, the Host creates a canonical, sorted JSON scope with these +dimensions: -No single fixed granularity covers all scenarios. A template-based approach is needed. +| Dimension | Purpose | +| --- | --- | +| `instance_id` | Isolate separate LangBot installations | +| `workspace_id` | Preserve workspace/tenant boundary when available | +| `bot_id` | Prevent two bots from sharing a sandbox accidentally | +| `platform_adapter` | Separate identical target ids from different adapters | +| `target_type` / `target_id` | Identify the platform session or event target | +| `thread_id` | Isolate threads within a target when available | ---- +The canonical JSON is domain-separated and hashed by the Host. Runner input, +runner config, and tool parameters are not trusted sources for this scope. -## 4. Design Overview +### 2.1 Target identity priority -Two key changes: +The Host resolves `target_type` / `target_id` in this order: -1. **Unified container**: exec, skills, and MCP all share the same container per - session scope. No more separate container pools. -2. **Configurable session scope**: `session_id` is generated from a template with - pipeline variables, configurable per pipeline. +1. For a Pipeline-backed run, use the exact Query launcher tuple. +2. For a pure EBA run, use `delivery.reply_target.target_type/target_id` + (`launcher_type/launcher_id` aliases are accepted). +3. If there is no delivery target, use `conversation_id`. +4. For a non-message event without a conversation, use `event_id`, producing + an event-scoped sandbox. -### 4.1 Unified Container with Multiple Mounts +The adapter class or declared adapter capability supplies platform adapter +identity. The Host includes the active LangBot instance, workspace, bot, and +thread dimensions when they exist. -A single container per session scope is created on first use. It has: +### 2.2 Stability and isolation -- **Primary mount**: default workspace at `/workspace` (from `default_host_workspace`) -- **Skill mounts**: each pipeline-bound skill's `package_root` mounted at - `/workspace/.skills/{skill_name}/` -- **MCP servers**: run as managed processes inside the same container +The same normalized scope always produces the same hash, so repeated runs in +the same platform conversation reuse the same Box workspace. A rotating +transcript/conversation id does not change the scope when an explicit platform +reply target remains the same. -``` -Container (session_id = "group_123456") - /workspace/ ← default workspace (bind mount, rw) - /workspace/.skills/web-search/ ← skill package (bind mount, rw) - /workspace/.skills/data-analysis/ ← skill package (bind mount, rw) - [managed process: mcp-server-a] ← MCP server running inside - [managed process: mcp-server-b] ← MCP server running inside +A different target, thread, workspace, bot, platform adapter, or LangBot +instance changes the hash. If delivery target is unavailable and +`conversation_id` is the fallback, different conversations also produce +different hashes. Event-scoped fallback isolates unrelated non-message events. + +### 2.3 Fail closed + +If the private Host scope marker is present but empty or malformed, Box rejects +execution with `BoxValidationError`. A direct Query without either a valid +Host scope or launcher/session identity is also rejected. There is no +`unknown`, raw query id, global, or caller-selected fallback. + +## 3. Host execution Query + +AgentRunner callbacks need a Host-owned Query view because model/tool loaders +already consume that type. The Query is internal and is never exposed as a +Runner-controlled object. + +- A Pipeline run stores the exact current Query in `AgentRunSession`. +- A pure EBA run builds a minimal Query with a valid Session and + `pipeline_config=None`, `pipeline_uuid=None`. +- The Host attaches canonical `_host_box_scope` and the authorized skill names + in `_pipeline_bound_skills`. +- `PluginToRuntimeAction.CALL_TOOL` restores this Query from the active + `run_id` before dispatching to `ToolManager`. + +This gives Pipeline and pure EBA execution the same Host tool path without +inventing a fake Pipeline for an independent Agent. + +## 4. AgentRunner callback paths + +AgentRunner implementations may use either callback transport: + +1. SDK/Python runners call `AgentRunAPIProxy.call_tool`. +2. External harnesses call the SDK-owned scoped MCP bridge. + +Both transports emit the same `PluginToRuntimeAction.CALL_TOOL`. The Host then +validates the same run authorization, restores the same execution Query, and +dispatches to the same ToolManager and BoxService. + +```text +AgentRunner + +-- AgentRunAPIProxy.call_tool --------+ + | | + +-- SDK-owned scoped MCP bridge -------+--> PluginToRuntimeAction.CALL_TOOL + --> run authorization + --> execution Query + --> ToolManager + --> BoxService + --> lb-box- ``` -This requires extending `BoxSpec` to support multiple mounts (see §5). +An AgentRunner is not required to use MCP. Local Python runners can use the SDK +directly; code-agent harnesses can use the bridge. The transports do not define +different authorization or sandbox semantics. -### 4.2 Session ID Template +## 5. Skills and mounts -A new field `box-session-id-template` in the `local-agent` pipeline runner config -controls the session scope: +Native exec and skill-backed exec for one Host scope use the same hashed +session. `BoxService.build_skill_extra_mounts(query)` adds visible, authorized +skill packages under `/workspace/.skills/` when the session is created. -```yaml -# templates/metadata/pipeline/ai.yaml (under local-agent.config) -- name: box-session-id-template - label: - en_US: Sandbox Scope - zh_Hans: 沙箱作用域 - description: - en_US: >- - Determines how sandbox environments are shared. Use variables to - control isolation granularity. - zh_Hans: >- - 决定沙箱环境的共享方式。使用变量控制隔离粒度。 - type: select - required: false - default: "{launcher_type}_{launcher_id}" - options: - - value: "{launcher_type}_{launcher_id}" - label: - en_US: Per chat (Recommended) - zh_Hans: 每个会话(推荐) - - value: "{launcher_type}_{launcher_id}_{sender_id}" - label: - en_US: Per user in chat - zh_Hans: 会话中每个用户 - - value: "{launcher_type}_{launcher_id}_{conversation_id}" - label: - en_US: Per conversation context - zh_Hans: 每个对话上下文 - - value: "{query_id}" - label: - en_US: Per message (isolated) - zh_Hans: 每条消息(完全隔离) -``` +Skill activation controls which skill-backed tools and paths are available. It +does not create a different session and does not grant the Runner authority to +change the session id. -Available template variables (populated by PreProcessor in `query.variables`): +## 6. `mcp-shared` is a different session -| Variable | Source | Example | -|---------------------|---------------------------------|----------------------| -| `{launcher_type}` | `query.session.launcher_type` | `person` / `group` | -| `{launcher_id}` | `query.session.launcher_id` | `123456` | -| `{sender_id}` | `query.sender_id` | `789` | -| `{conversation_id}` | `conversation.uuid` | `a1b2c3d4-...` | -| `{query_id}` | `query.query_id` | `42` | +LangBot can host configured stdio MCP servers as managed processes inside Box. +Those long-lived infrastructure processes share the dedicated `mcp-shared` +session and are isolated from one another by `process_id`. -Default `{launcher_type}_{launcher_id}` covers scenarios 1–4 out of the box. +This is separate from the scoped MCP bridge above: ---- +| Path | Purpose | Session rule | +| --- | --- | --- | +| AgentRunner scoped MCP bridge | Call authorized Host tools for one active run | Host-owned `lb-box-` from the run execution Query | +| MCP-in-Box stdio server | Keep configured MCP server processes running | Dedicated persistent `mcp-shared` session | -## 5. SDK Changes: Multi-Mount BoxSpec +Calling a sandbox tool through the AgentRunner bridge never redirects the run +workspace into `mcp-shared`. Conversely, an MCP server's managed-process +lifecycle does not inherit the current event scope. -### 5.1 Model Extension +## 7. Configuration and compatibility -```python -# box/models.py +There is no Box session scope field in Pipeline metadata, AgentRunner config, +or the public Pipeline/Runner API. Operators configure the Box subsystem itself +(`box.enabled`, backend/runtime settings, profiles, mount allowlists, quotas, +and workspace roots), not per-Runner session templates. -class BoxMountSpec(pydantic.BaseModel): - """A single bind mount specification.""" - host_path: str - mount_path: str - mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE +Old configuration containing `box-session-id-template` is unsupported in the +4.x contract. LangBot 4.x does not migrate LangBot 3.x configuration or +databases, so the removed field is not read as a compatibility fallback. -class BoxSpec(pydantic.BaseModel): - # ... existing fields ... - host_path: str | None = None # Primary mount (backward compat) - host_path_mode: BoxHostMountMode = BoxHostMountMode.READ_WRITE - mount_path: str = DEFAULT_BOX_MOUNT_PATH - extra_mounts: list[BoxMountSpec] = [] # NEW: additional mounts -``` +## 8. Regression coverage -`extra_mounts` is additive — the existing `host_path` / `mount_path` pair remains -the primary mount for backward compatibility. +Release tests should prove: -### 5.2 Backend: Apply Extra Mounts - -```python -# box/backend.py — CLISandboxBackend.start_session() - -# Primary mount (unchanged) -if spec.host_path is not None and spec.host_path_mode != BoxHostMountMode.NONE: - args.extend(['-v', f'{spec.host_path}:{spec.mount_path}:{spec.host_path_mode.value}']) - -# Extra mounts (NEW) -for mount in spec.extra_mounts: - if mount.mode != BoxHostMountMode.NONE: - args.extend(['-v', f'{mount.host_path}:{mount.mount_path}:{mount.mode.value}']) -``` - -Same pattern for nsjail backend. - ---- - -## 6. LangBot Changes - -### 6.1 Session ID Resolution - -In `BoxService.execute_tool()`: - -```python -# Before: -spec_payload.setdefault('session_id', str(query.query_id)) - -# After: -template = (query.pipeline_config or {}).get('ai', {}) \ - .get('local-agent', {}).get('box-session-id-template', - '{launcher_type}_{launcher_id}') -variables = query.variables or {} -session_id = template.format_map(collections.defaultdict( - lambda: 'unknown', variables -)) -spec_payload.setdefault('session_id', session_id) -``` - -### 6.2 Skill Exec: Use Same Container - -Currently `native.py:_invoke_exec` creates a separate `BoxWorkspaceSession` per -skill with `host_path=package_root`. Instead: - -1. Use the **same session_id** as default exec (from the template). -2. Pass the skill's `package_root` as an **extra mount** at - `/workspace/.skills/{skill_name}/` instead of replacing `/workspace`. -3. The container already has the default workspace at `/workspace`. - -```python -# native.py — _invoke_exec, skill branch (REVISED) - -# Same session_id as default exec -session_id = resolve_box_session_id(query) - -spec_payload = { - 'cmd': rewritten_command, - 'workdir': rewritten_workdir, - 'session_id': session_id, - 'extra_mounts': [{ - 'host_path': package_root, - 'mount_path': f'/workspace/.skills/{selected_skill_name}', - 'mode': 'rw', - }], -} -result = await self.ap.box_service.execute_spec_payload(spec_payload, query) -``` - -The virtual path `/workspace/.skills/{name}` no longer needs rewriting at the -command level — it maps directly to the bind mount path inside the container. - -### 6.3 MCP: Use Same Container - -MCP servers should run inside the same container as exec and skills. Changes: - -1. `BoxStdioSessionRuntime` uses the pipeline's session_id template instead of - `mcp-{server_uuid}`. -2. MCP server's working directory is a subdirectory (e.g. `/workspace/.mcp/{name}/`). -3. MCP server's dependencies are mounted or installed into that subdirectory. -4. The MCP server runs as a managed process inside the shared container. - -Since MCP servers start at LangBot boot (not per-query), the session must be -created eagerly. The container will be kept alive by the managed process -exemption in TTL reaping (`runtime.py:259`). - -**Note**: MCP sessions are pipeline-scoped (not per-launcher), so their session_id -should be a **fixed identifier per pipeline** rather than the user-facing template. -This means one shared MCP container per pipeline, with user exec sessions separate. - -Alternatively, in a future iteration, MCP managed processes could be launched -lazily into the user's container on first MCP tool call. This is more complex -but maximizes sharing. For V1, keeping MCP containers at pipeline scope is -simpler and more predictable. - ---- - -## 7. Mount Layout Summary - -### Default exec (no skills activated) - -``` -Container (session_id from template) - /workspace/ ← default_host_workspace (rw) -``` - -### Exec with activated skills - -``` -Container (same session_id) - /workspace/ ← default_host_workspace (rw) - /workspace/.skills/web-search/ ← skill package_root (rw) - /workspace/.skills/data-analysis/ ← skill package_root (rw) -``` - -Extra mounts are **additive** — they are added when the container is first -created (or on the first exec that references a skill). Since Docker bind -mounts are specified at container creation time, skills must be known at -creation time. - -**Resolution**: When creating a container, inject `extra_mounts` for **all -pipeline-bound skills** (from `extensions_preferences`), not just the -currently activated one. This way any skill can be activated later without -recreating the container. - -### MCP servers (V1: pipeline-scoped) - -``` -Container (session_id = "mcp-pipeline-{pipeline_uuid}") - /workspace/ ← MCP shared workspace - /workspace/.mcp/server-a/ ← MCP server A files - /workspace/.mcp/server-b/ ← MCP server B files - [managed process: server-a] - [managed process: server-b] -``` - ---- - -## 8. Data Migration - -Existing pipelines do not have `box-session-id-template`. The backend uses -`.get(..., default)` so missing keys fall back to `{launcher_type}_{launcher_id}`. -This changes behavior from per-message to per-launcher for existing pipelines. - -Recommendation: **accept the behavior change** — per-launcher is the more -intuitive default, and the old per-message behavior was rarely desired. - ---- - -## 9. Cloud Quota Implications - -| Scope | Typical concurrent containers | -|-----------------------------------------------|-------------------------------| -| `{query_id}` (per message) | Many, short-lived | -| `{launcher_type}_{launcher_id}` (per chat) | = active chat count | -| `{sender_id}` (per user) | = active user count | -| `{conversation_id}` (per conversation) | Between per-chat and per-msg | - -With the unified container model, each scope value maps to exactly **one** -container (instead of potentially 3+ per-message). This significantly reduces -resource usage. - -Quota enforcement point: `BoxRuntime._get_or_create_session()` in the SDK. - ---- - -## 10. Implementation Phases - -### Phase 1: Session scope + skill unification (this PR) - -1. **SDK**: Extend `BoxSpec` with `extra_mounts: list[BoxMountSpec]`. -2. **SDK**: Update Docker/nsjail backends to apply extra mounts. -3. **LangBot**: Add `box-session-id-template` to `local-agent` YAML metadata - and default pipeline config JSON. -4. **LangBot**: Update `BoxService.execute_tool()` to use template interpolation. -5. **LangBot**: Update `native.py:_invoke_exec` skill branch to use same - session_id + extra mounts instead of separate `BoxWorkspaceSession`. -6. **LangBot**: On container creation, inject extra mounts for all - pipeline-bound skills. -7. **Frontend**: No code change — `DynamicFormComponent` renders `select` fields. -8. **Tests**: Unit tests for template interpolation and multi-mount specs. - -### Phase 2: MCP unification (future) - -1. Refactor `BoxStdioSessionRuntime` to use pipeline-scoped shared container. -2. MCP servers become managed processes in the shared container. -3. Support multiple concurrent managed processes per container. - -MCP unification is deferred because it requires changes to the managed process -model (currently 1 managed process per session) and has startup ordering -concerns (MCP servers start at boot, before any user query determines -a session_id). +- every event-run session id matches `lb-box-[0-9a-f]{64}` and contains no raw + identity; +- the same canonical Host scope is stable while different targets, + conversations, threads, bots, adapters, workspaces, or instances are + isolated; +- Pipeline and pure EBA runs representing the same platform session produce + the same canonical scope; +- missing Host/Query identity fails closed; +- SDK/Python `call_tool` and the scoped MCP bridge both enter + `PluginToRuntimeAction.CALL_TOOL` and restore the run execution Query; +- Runner payload/config cannot override the session id; +- stdio MCP processes remain in `mcp-shared` and are isolated by process id; +- authorized skills are mounted into the hashed run session without creating + per-skill sessions. diff --git a/docs/review/box-test-coverage.md b/docs/review/box-test-coverage.md index 995e6970b..cbe2b9786 100644 --- a/docs/review/box-test-coverage.md +++ b/docs/review/box-test-coverage.md @@ -1,6 +1,6 @@ # Box 系统测试覆盖分析 -> 更新日期: 2026-06-02 +> 更新日期: 2026-07-12 > 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。 > 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk) @@ -15,13 +15,14 @@ | `tests/unit_tests/box/test_box_connector.py` | 106 | 是 | Connector 传输决策、WS relay URL、dispose、心跳/重连 | | `tests/unit_tests/box/test_box_service.py` | 1224 | 是 | Service 核心逻辑(最全面) | | `tests/unit_tests/box/test_workspace.py` | 147 | 是 | WorkspaceSession 路径重写、payload 构建 | +| `tests/unit_tests/agent/test_execution_context.py` | 144 | 是 | Pipeline/纯 EBA execution Query、canonical Host scope、adapter/instance 隔离 | +| `tests/unit_tests/plugin/test_handler_actions.py` | 1071 | 是 | AgentRun CALL_TOOL 恢复 execution Query、纯 EBA native exec | | `tests/unit_tests/provider/test_mcp_box_integration.py` | 707 | 是 | MCP Box 配置、路径重写、payload、shared-session/multi-process、runtime info | -| `tests/unit_tests/provider/test_localagent_sandbox_exec.py` | 444 | 是 | LocalAgent exec 流程、流式、Skill 激活 (Tool Call) | | `tests/unit_tests/provider/test_tool_manager_native.py` | 249 | 是 | ToolManager 路由、native tool CRUD、路径穿越、6 工具暴露 | | `tests/unit_tests/provider/test_skill_tools.py` | 582 | 是 | Skill 管理、Tool Call 激活、路径、authoring CRUD | | `tests/unit_tests/test_skill_service.py` | 396 | 是 | HTTP service:skill CRUD、zip/GitHub install、文件浏览 | | `tests/unit_tests/test_paths.py` | 23 | 是 | paths 工具 | -| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 注入 session 变量、bound skill 解析 | +| `tests/unit_tests/test_preproc.py` | 134 | 是 | PreProcessor 的模型、历史与 bound skill 解析 | | `tests/unit_tests/pipeline/test_chat_handler_logging.py` | 78 | 是 | Chat handler 日志相关回归 | | `tests/integration_tests/box/test_box_integration.py` | 329 | **否** | 真实容器执行、超时、网络隔离 | | `tests/integration_tests/box/test_box_mcp_integration.py` | 368 | **否** | Managed process、WS attach、shared-session 清理 | @@ -35,7 +36,7 @@ | `tests/box/test_e2b_backend.py` | 482 | 是 | E2B SDK mock、session 生命周期、extra_mounts 同步 | | `tests/box/test_skill_store.py` | 88 | 是 | zip preview/install、基础 file CRUD | -**总计**: 17 个测试文件, ~6,500 行测试代码; 其中 2 个集成测试(约 700 行)在 CI 中不运行。 +**说明**: 本表按当前主链列出 Box 相关测试;其中 2 个真实容器集成测试默认不在 CI 中运行。 > 较 2026-04-16 版增加:`test_skill_service.py`、`test_paths.py`、`test_preproc.py`、`test_chat_handler_logging.py` (LangBot),`test_backend_selection.py`、`test_e2b_backend.py`、`test_skill_store.py` (SDK)。`test_nsjail_backend.py` 增加 CLI 兼容性 case (commit `feed530`)。 @@ -51,7 +52,7 @@ | BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | | BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | | BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | -| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 | +| BoxService Host-owned session | 优秀 | 覆盖 `lb-box-` 固定格式、原始 identity 不泄露、同 scope 稳定、不同 conversation/scope 隔离、缺 identity fail closed | | RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | | BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | | BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | @@ -61,7 +62,7 @@ | Backend selection | 良好 | 显式 backend 优先级、local 探测顺序、配置变更触发 reselect | | MCP Box 集成 | 良好 | config model、路径重写、payload、shared-session 多 process | | Native tool loader | 良好 | 6 工具(exec/read/write/edit/glob/grep)、路径穿越拦截 | -| LocalAgent exec 流程 | 良好 | 完整 tool call 循环、流式、system prompt 注入、Tool Call 激活 | +| AgentRunner 工具入口 | 良好 | SDK proxy 与 MCP bridge 都映射到 `PluginToRuntimeAction.CALL_TOOL`;Host action 测试覆盖 run-scoped execution Query 与纯 EBA native exec | | Skill 系统 | 良好 | 加载、Tool Call 激活、marker、路径解析、authoring CRUD、HTTP service | --- diff --git a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs index 69cca52a4..bf8c1835b 100755 --- a/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs +++ b/skills/scripts/e2e/ensure-fake-provider-pipeline.mjs @@ -522,7 +522,6 @@ async function ensurePipeline({ backendUrl, token, name, modelUuid }) { prompt: [{ role: "system", content: "You are a deterministic QA assistant. Reply exactly as instructed." }], "remove-think": false, "knowledge-bases": [], - "box-session-id-template": "{launcher_type}_{launcher_id}", "retrieval-top-k": 5, "rerank-model": "", "rerank-top-k": 5, diff --git a/skills/scripts/e2e/pipeline-debug-chat.mjs b/skills/scripts/e2e/pipeline-debug-chat.mjs index 93476be82..7d96b4c2e 100755 --- a/skills/scripts/e2e/pipeline-debug-chat.mjs +++ b/skills/scripts/e2e/pipeline-debug-chat.mjs @@ -112,7 +112,9 @@ function parseJsonEnv(key, fallback) { } function positiveNumberEnv(key, fallback) { - const value = Number(env[key] || ""); + const raw = env[key]; + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); return Number.isFinite(value) && value >= 0 ? value : fallback; } diff --git a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml index 578cd8a31..f56124570 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-ledger-invariants.yaml @@ -17,7 +17,7 @@ env: automation: skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs steps: - "Run `rtk bin/lbs test run agent-runner-ledger-invariants --dry-run` first; remove `--dry-run` after checking the planned evidence directory." - - "Automation resolves LANGBOT_REPO, defaulting to ../LangBot, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../langbot-plugin-sdk/src." + - "Automation resolves LANGBOT_REPO, defaulting to the parent LangBot checkout, and imports the sibling SDK from LANGBOT_PLUGIN_SDK_REPO or ../../langbot-plugin-sdk/src." - "Automation checks run status sets, terminal status validation, ledger table/index DDL, and a minimal synchronous insert/read path." checks: - "automation-result.json status is pass." diff --git a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml index 1d86b5ec7..afcee8fa6 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-qa-debug-chat.yaml @@ -33,6 +33,7 @@ automation_expected_runner_id: "plugin:qa/agent-runner/default" automation_prompt: "hello-live" automation_expected_text: "QA_AGENT_RUNNER_OK:hello-live" automation_response_timeout_ms: "120000" +automation_debug_chat_response_p95_ms: "120000" automation_reset_debug_chat: "1" setup_automation: - "case:agent-runner-live-install" diff --git a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml index d7aeca392..6c4a70149 100644 --- a/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml +++ b/skills/skills/langbot-testing/cases/agent-runner-runtime-chaos.yaml @@ -18,7 +18,7 @@ env: automation: skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs steps: - "Run `rtk bin/lbs test run agent-runner-runtime-chaos --dry-run` first; remove `--dry-run` after checking the SDK repo target and evidence directory." - - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../langbot-plugin-sdk when the env var is unset." + - "Automation resolves LANGBOT_PLUGIN_SDK_REPO, defaulting to ../../langbot-plugin-sdk when the env var is unset." - "Automation runs the existing SDK pytest files tests/runtime/plugin/test_mgr_agent_runner.py and tests/runtime/test_pull_api_handlers.py." checks: - "automation-result.json status is pass." @@ -28,7 +28,7 @@ evidence_required: - filesystem diagnostics: - "This probe does not open the WebUI; it runs SDK pytest targets directly." - - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." + - "env_issue means LANGBOT_PLUGIN_SDK_REPO/default ../../langbot-plugin-sdk did not resolve, rtk/uv was unavailable, or the expected test files are missing." - "fail means the existing SDK runtime pytest target failed or timed out." success_patterns: - "pytest passed" diff --git a/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml index 88aff2767..1c7073374 100644 --- a/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-combo-rag-compaction-tool-debug-chat.yaml @@ -37,7 +37,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":650,"context-reserve-tokens":180,"context-keep-recent-tokens":120,"context-summary-tokens":220,"max-tool-iterations":4,"tool-execution-mode":"serial"}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml index 0ad9b8866..637f67db8 100644 --- a/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-context-compaction-debug-chat.yaml @@ -33,7 +33,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"context-window-tokens":225,"context-reserve-tokens":50,"context-keep-recent-tokens":30,"context-summary-tokens":105,"knowledge-bases":[]}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml index 6b90a9e5a..c9d530cbf 100644 --- a/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-multitool-rag-compaction-debug-chat.yaml @@ -38,7 +38,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":5,"tool-execution-mode":"serial"}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml index 13b6848bb..704ac9975 100644 --- a/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-parallel-tools-rag-compaction-debug-chat.yaml @@ -38,7 +38,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":["${LANGBOT_LOCAL_AGENT_RAG_KB_UUID}"],"retrieval-top-k":1,"context-window-tokens":900,"context-reserve-tokens":220,"context-keep-recent-tokens":160,"context-summary-tokens":260,"max-tool-iterations":3,"tool-execution-mode":"parallel"}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml index 3a44ebc29..8b30ab8f7 100644 --- a/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-tool-error-recovery-debug-chat.yaml @@ -34,7 +34,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":3,"tool-execution-mode":"serial"}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml b/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml index 55f82abf8..0788fd580 100644 --- a/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml +++ b/skills/skills/langbot-testing/cases/local-agent-tool-loop-limit-debug-chat.yaml @@ -35,7 +35,7 @@ automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" automation_runner_config_patch_json: '{"knowledge-bases":[],"max-tool-iterations":2,"tool-execution-mode":"serial"}' -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"},{"author":"qa","name":"plugin-smoke"}],"enable_all_mcp_servers":false,"bound_mcp_servers":[],"enable_all_skills":false,"bound_skills":[]}' automation_restore_runner_config: "1" automation_restore_extensions: "1" automation_reset_debug_chat: "1" diff --git a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml index 9ab9a06bc..093e23d50 100644 --- a/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml +++ b/skills/skills/langbot-testing/cases/mcp-stdio-tool-call.yaml @@ -32,7 +32,7 @@ automation_env: automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME automation_expected_runner_id: "plugin:langbot-team/LocalAgent/default" -automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot","name":"local-agent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":["${LANGBOT_MCP_QA_STDIO_SERVER_UUID}"],"enable_all_skills":false,"bound_skills":[]}' +automation_extensions_patch_json: '{"enable_all_plugins":false,"bound_plugins":[{"author":"langbot-team","name":"LocalAgent"}],"enable_all_mcp_servers":false,"bound_mcp_servers":["${LANGBOT_MCP_QA_STDIO_SERVER_UUID}"],"enable_all_skills":false,"bound_skills":[]}' automation_restore_extensions: "1" automation_reset_debug_chat: "1" automation_prompt: "Call the qa_mcp_echo MCP tool with exactly this text: mcp-ok-local-agent. Return only the tool result." diff --git a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs index 3bf1facf0..24652f03a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-async-db-readiness.mjs @@ -80,13 +80,17 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_ASYNC_DB_READINESS_TIMEOUT_MS || "5000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "aiosqlite-readiness", diff --git a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs index bc7ded87b..f6d8a2419 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-behavior-matrix.mjs @@ -142,15 +142,20 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const fixturePath = resolve(root, "skills/langbot-testing/fixtures/agent-runner/qa-runner-behaviors.json"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, fixturePath], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-behavior-matrix", diff --git a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs index 00c447f71..31fea1c87 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-fixture-contract.mjs @@ -129,7 +129,7 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk"); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); const sdkSrc = resolve(sdkRepo, "src"); const fixturePath = resolve(root, "skills/langbot-testing/fixtures/plugins/qa-agent-runner"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); @@ -137,7 +137,7 @@ async function main() { const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, fixturePath], cwd: sdkRepo }; + const command = { executable: "rtk", args: ["uv", "run", "--no-sync", "python", "-c", script, fixturePath], cwd: sdkRepo }; const result = { source: "automation", probe: "agent-runner-fixture-contract", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs index f6c98389a..3ce2e9d3b 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-concurrency.mjs @@ -5,9 +5,9 @@ import { runPytestProbe } from "./pytest-probe.mjs"; await runPytestProbe({ caseId: "agent-runner-ledger-concurrency", repoEnvKey: "LANGBOT_REPO", - defaultRepo: "../LangBot", + defaultRepo: "..", pythonPathEnvKeys: ["LANGBOT_PLUGIN_SDK_REPO"], - defaultPythonPaths: ["../langbot-plugin-sdk/src"], + defaultPythonPaths: ["../../langbot-plugin-sdk/src"], description: "LangBot AgentRunner run ledger claim, lease, authorization, and runtime-admin pytest probe.", testTargets: [ "tests/unit_tests/agent/test_run_ledger_store.py::test_create_queued_run_claim_renew_release", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs index ec4670011..e8c54d21a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-contention.mjs @@ -152,15 +152,20 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const dbPath = join(evidenceDir, "ledger-contention.sqlite3"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script, dbPath], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script, dbPath], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-ledger-contention", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs index 1b5416d10..273ae50c9 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-invariants.mjs @@ -127,13 +127,18 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolveFromRoot(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolveFromRoot(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", probeScript], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", probeScript], + cwd: langbotRepo, + }; const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); const result = { source: "automation", diff --git a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs index 3575358a2..97a3a2c40 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-ledger-stress.mjs @@ -119,14 +119,19 @@ async function main() { const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join(root, "reports", "evidence", runId)); await mkdir(evidenceDir, { recursive: true }); const startedAt = new Date(); - const langbotRepo = resolve(root, env.LANGBOT_REPO || "../LangBot"); - const sdkSrc = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../langbot-plugin-sdk/src"); + const langbotRepo = resolve(root, env.LANGBOT_REPO || ".."); + const sdkRepo = resolve(root, env.LANGBOT_PLUGIN_SDK_REPO || "../../langbot-plugin-sdk"); + const sdkSrc = resolve(sdkRepo, "src"); const stdoutLog = join(evidenceDir, "probe-stdout.log"); const stderrLog = join(evidenceDir, "probe-stderr.log"); const automationResultJson = join(evidenceDir, "automation-result.json"); const resultJson = join(evidenceDir, "result.json"); const timeoutMs = Number(env.LANGBOT_AGENT_RUNNER_PROBE_TIMEOUT_MS || "30000"); - const command = { executable: "rtk", args: ["uv", "run", "python", "-c", script], cwd: langbotRepo }; + const command = { + executable: "rtk", + args: [resolve(langbotRepo, ".venv/bin/python"), "-c", script], + cwd: langbotRepo, + }; const result = { source: "automation", probe: "agent-runner-ledger-stress", diff --git a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs index 07ee5f988..41c31fe3a 100755 --- a/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs +++ b/skills/skills/langbot-testing/probes/agent-runner-runtime-chaos.mjs @@ -5,7 +5,7 @@ import { runPytestProbe } from "./pytest-probe.mjs"; await runPytestProbe({ caseId: "agent-runner-runtime-chaos", repoEnvKey: "LANGBOT_PLUGIN_SDK_REPO", - defaultRepo: "../langbot-plugin-sdk", + defaultRepo: "../../langbot-plugin-sdk", description: "LangBot plugin SDK AgentRunner runtime failure, timeout, forwarding, and pull API pytest probe.", testTargets: [ "tests/runtime/plugin/test_mgr_agent_runner.py", diff --git a/skills/skills/langbot-testing/probes/pytest-probe.mjs b/skills/skills/langbot-testing/probes/pytest-probe.mjs index db98361e4..9e7090f21 100755 --- a/skills/skills/langbot-testing/probes/pytest-probe.mjs +++ b/skills/skills/langbot-testing/probes/pytest-probe.mjs @@ -129,7 +129,7 @@ export async function runPytestProbe({ const resultJson = join(evidenceDir, "result.json"); const command = { executable: "rtk", - args: ["uv", "run", "pytest", "-q", ...testTargets], + args: ["uv", "run", "--no-sync", "pytest", "-q", ...testTargets], cwd: repoPath, }; const result = { diff --git a/skills/test/lbs-cli.test.ts b/skills/test/lbs-cli.test.ts index 56ab432e5..221f2b2a5 100644 --- a/skills/test/lbs-cli.test.ts +++ b/skills/test/lbs-cli.test.ts @@ -3589,7 +3589,7 @@ test("MCP stdio tool-call case setups pipeline and registered MCP server", () => JSON.parse(run.automation.env_defaults.LANGBOT_E2E_EXTENSIONS_PATCH_JSON), { enable_all_plugins: false, - bound_plugins: [{ author: "langbot", name: "local-agent" }], + bound_plugins: [{ author: "langbot-team", name: "LocalAgent" }], enable_all_mcp_servers: false, bound_mcp_servers: ["mcp-server-uuid"], enable_all_skills: false, @@ -3695,6 +3695,10 @@ test("AgentRunner QA Debug Chat case uses dedicated pipeline env", () => { run.automation.env_defaults.LANGBOT_E2E_EXPECTED_RUNNER_ID, "plugin:qa/agent-runner/default", ); + assert.equal( + run.automation.env_defaults.LANGBOT_E2E_DEBUG_CHAT_RESPONSE_P95_MS, + "120000", + ); assert.deepEqual( run.setup_automation.map((item: { entry: string }) => item.entry), [ diff --git a/src/langbot/pkg/agent/__init__.py b/src/langbot/pkg/agent/__init__.py index 4da739d70..df183b2cb 100644 --- a/src/langbot/pkg/agent/__init__.py +++ b/src/langbot/pkg/agent/__init__.py @@ -1,4 +1,5 @@ """Agent runner subsystem for LangBot.""" + from __future__ import annotations from .runner.descriptor import AgentRunnerDescriptor @@ -15,7 +16,7 @@ from .runner.context_builder import AgentRunContextBuilder from .runner.resource_builder import AgentResourceBuilder from .runner.result_normalizer import AgentResultNormalizer from .runner.orchestrator import AgentRunOrchestrator -from .runner.config_migration import ConfigMigration +from .runner.config_resolver import RunnerConfigResolver __all__ = [ 'AgentRunnerDescriptor', @@ -33,5 +34,5 @@ __all__ = [ 'AgentResourceBuilder', 'AgentResultNormalizer', 'AgentRunOrchestrator', - 'ConfigMigration', -] \ No newline at end of file + 'RunnerConfigResolver', +] diff --git a/src/langbot/pkg/agent/runner/__init__.py b/src/langbot/pkg/agent/runner/__init__.py index 0dc533aa7..03ce838d5 100644 --- a/src/langbot/pkg/agent/runner/__init__.py +++ b/src/langbot/pkg/agent/runner/__init__.py @@ -16,7 +16,7 @@ from .context_builder import AgentRunContextBuilder from .resource_builder import AgentResourceBuilder from .result_normalizer import AgentResultNormalizer from .orchestrator import AgentRunOrchestrator -from .config_migration import ConfigMigration +from .config_resolver import RunnerConfigResolver from .default_config import AgentRunnerDefaultConfigService from .binding_resolver import AgentBindingResolver, AgentBindingResolutionError from .session_registry import ( @@ -49,7 +49,7 @@ __all__ = [ 'AgentResourceBuilder', 'AgentResultNormalizer', 'AgentRunOrchestrator', - 'ConfigMigration', + 'RunnerConfigResolver', 'AgentRunnerDefaultConfigService', 'AgentBindingResolver', 'AgentBindingResolutionError', diff --git a/src/langbot/pkg/agent/runner/config_migration.py b/src/langbot/pkg/agent/runner/config_migration.py deleted file mode 100644 index 93a2a4096..000000000 --- a/src/langbot/pkg/agent/runner/config_migration.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Helpers for the current AgentRunner config shape.""" - -from __future__ import annotations - -import typing - - -class ConfigMigration: - """Configuration helpers for the current AgentRunner shape. - - Responsibilities: - - Resolve runner ID from ai.runner.id - - Extract Agent/runner config from ai.runner_config - - Read current conversation expiry settings - """ - - @staticmethod - def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: - """Resolve runner ID from current configuration. - - Args: - pipeline_config: Current configuration container - - Returns: - Runner ID string, or None if not configured - """ - ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} - runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} - runner_id = runner.get('id') if isinstance(runner, dict) else None - return runner_id if isinstance(runner_id, str) and runner_id else None - - @staticmethod - def resolve_runner_config( - pipeline_config: dict[str, typing.Any], - runner_id: str, - ) -> dict[str, typing.Any]: - """Resolve Agent/runner configuration from the current container. - - Args: - pipeline_config: Current configuration container - runner_id: Resolved runner ID - - Returns: - Runner configuration dict (empty if not found) - """ - ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} - runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {} - runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {} - return runner_config if isinstance(runner_config, dict) else {} - - @staticmethod - def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: - """Get conversation expire time from configuration. - - Args: - pipeline_config: Current configuration container - - Returns: - Expire time in seconds (0 means no expiry) - """ - ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} - runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} - expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0 - return expire_time if isinstance(expire_time, int) else 0 diff --git a/src/langbot/pkg/agent/runner/config_resolver.py b/src/langbot/pkg/agent/runner/config_resolver.py new file mode 100644 index 000000000..378322f10 --- /dev/null +++ b/src/langbot/pkg/agent/runner/config_resolver.py @@ -0,0 +1,199 @@ +"""Resolve the current AgentRunner configuration shape.""" + +from __future__ import annotations + +import typing + + +HOST_SECURITY_BOOLEAN_FIELDS = ( + 'enable-all-tools', + 'mcp-resource-agent-read-enabled', +) + + +class RunnerConfigResolver: + """Configuration helpers for the current AgentRunner shape. + + Responsibilities: + - Resolve runner ID from ai.runner.id + - Extract Agent/runner config from ai.runner_config + - Read current conversation expiry settings + - Validate the persisted 4.x Agent binding container + """ + + @staticmethod + def validate_agent_config(config: typing.Any) -> dict[str, typing.Any]: + """Validate and return the persisted 4.x Agent config container.""" + if not isinstance(config, dict): + raise ValueError('Agent config must be an object') + + runner = config.get('runner') + if not isinstance(runner, dict): + raise ValueError("Agent config field 'runner' must be an object") + + runner_id = runner.get('id') + if not isinstance(runner_id, str): + raise ValueError("Agent config field 'runner.id' must be a string") + + runner_configs = config.get('runner_config') + if not isinstance(runner_configs, dict): + raise ValueError("Agent config field 'runner_config' must be an object") + + for configured_runner_id, runner_config in runner_configs.items(): + if not isinstance(configured_runner_id, str) or not configured_runner_id: + raise ValueError("Agent config field 'runner_config' must use non-empty string runner IDs") + if not isinstance(runner_config, dict): + raise ValueError(f'Agent runner_config[{configured_runner_id!r}] must be an object') + + if runner_id and runner_id not in runner_configs: + raise ValueError(f'Agent runner_config is missing selected runner {runner_id!r}') + + if runner_id: + RunnerConfigResolver.validate_runner_security_fields( + runner_configs[runner_id], + context=f'Agent runner_config[{runner_id!r}]', + ) + + return config + + @staticmethod + def validate_runner_security_fields( + runner_config: dict[str, typing.Any], + *, + context: str = 'Runner config', + ) -> dict[str, typing.Any]: + """Reject malformed Host-owned security toggles instead of enabling them.""" + for field_name in HOST_SECURITY_BOOLEAN_FIELDS: + if field_name in runner_config and not isinstance(runner_config[field_name], bool): + raise ValueError(f'{context} field {field_name!r} must be a boolean') + + RunnerConfigResolver.validate_mcp_resource_attachments( + runner_config.get('mcp-resources'), + context=context, + field_name='mcp-resources', + ) + return runner_config + + @staticmethod + def validate_mcp_resource_attachments( + resources: typing.Any, + *, + context: str, + field_name: str, + ) -> typing.Any: + """Validate explicit attachment enable flags shared by Agent and Pipeline inputs.""" + if not isinstance(resources, list): + return resources + for index, resource in enumerate(resources): + if not isinstance(resource, dict) or 'enabled' not in resource: + continue + if not isinstance(resource['enabled'], bool): + raise ValueError(f"{context} field '{field_name}[{index}].enabled' must be a boolean") + return resources + + @classmethod + def validate_pipeline_config(cls, config: typing.Any) -> dict[str, typing.Any]: + """Validate the selected Runner container in a current 4.x Pipeline config.""" + if not isinstance(config, dict): + raise ValueError('Pipeline config must be an object') + + ai_config = config.get('ai') + if ai_config is None: + return config + if not isinstance(ai_config, dict): + raise ValueError("Pipeline config field 'ai' must be an object") + + runner = ai_config.get('runner') + if runner is None: + return config + if not isinstance(runner, dict): + raise ValueError("Pipeline config field 'ai.runner' must be an object") + + runner_id = runner.get('id') + if not isinstance(runner_id, str): + raise ValueError("Pipeline config field 'ai.runner.id' must be a string") + if not runner_id: + return config + + runner_configs = ai_config.get('runner_config') + if not isinstance(runner_configs, dict): + raise ValueError("Pipeline config field 'ai.runner_config' must be an object") + if runner_id not in runner_configs: + raise ValueError(f'Pipeline runner_config is missing selected runner {runner_id!r}') + + selected_config = runner_configs[runner_id] + if not isinstance(selected_config, dict): + raise ValueError(f'Pipeline runner_config[{runner_id!r}] must be an object') + cls.validate_runner_security_fields( + selected_config, + context=f'Pipeline runner_config[{runner_id!r}]', + ) + return config + + @staticmethod + def resolve_agent_runner_id(config: dict[str, typing.Any]) -> str | None: + """Resolve a runner ID from a validated persisted Agent config.""" + runner = config.get('runner', {}) + runner_id = runner.get('id') if isinstance(runner, dict) else None + return runner_id if isinstance(runner_id, str) and runner_id else None + + @classmethod + def resolve_agent_runner_config( + cls, + config: typing.Any, + ) -> tuple[dict[str, typing.Any], str | None, dict[str, typing.Any]]: + """Validate an Agent config and return its selected runner configuration.""" + validated = cls.validate_agent_config(config) + runner_id = cls.resolve_agent_runner_id(validated) + runner_configs = typing.cast(dict[str, typing.Any], validated['runner_config']) + runner_config = runner_configs.get(runner_id, {}) if runner_id else {} + return validated, runner_id, typing.cast(dict[str, typing.Any], runner_config) + + @staticmethod + def resolve_runner_id(pipeline_config: dict[str, typing.Any]) -> str | None: + """Resolve runner ID from current configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Runner ID string, or None if not configured + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} + runner_id = runner.get('id') if isinstance(runner, dict) else None + return runner_id if isinstance(runner_id, str) and runner_id else None + + @staticmethod + def resolve_runner_config( + pipeline_config: dict[str, typing.Any], + runner_id: str, + ) -> dict[str, typing.Any]: + """Resolve Agent/runner configuration from the current container. + + Args: + pipeline_config: Current configuration container + runner_id: Resolved runner ID + + Returns: + Runner configuration dict (empty if not found) + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {} + runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {} + return runner_config if isinstance(runner_config, dict) else {} + + @staticmethod + def get_expire_time(pipeline_config: dict[str, typing.Any]) -> int: + """Get conversation expire time from configuration. + + Args: + pipeline_config: Current configuration container + + Returns: + Expire time in seconds (0 means no expiry) + """ + ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} + runner = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} + expire_time = runner.get('expire-time', 0) if isinstance(runner, dict) else 0 + return expire_time if isinstance(expire_time, int) else 0 diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 7fd86e04c..87656d5f9 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -75,6 +75,9 @@ class ToolResource(typing.TypedDict): tool_type: str | None description: str | None operations: list[str] + parameters: dict[str, typing.Any] | None + source: typing.NotRequired[str] + source_id: typing.NotRequired[str | None] class KnowledgeBaseResource(typing.TypedDict): diff --git a/src/langbot/pkg/agent/runner/default_config.py b/src/langbot/pkg/agent/runner/default_config.py index 6c884fb4e..a131dac9b 100644 --- a/src/langbot/pkg/agent/runner/default_config.py +++ b/src/langbot/pkg/agent/runner/default_config.py @@ -7,7 +7,7 @@ import sqlalchemy from ...core import app from ...entity.persistence import pipeline as persistence_pipeline from . import config_schema -from .config_migration import ConfigMigration +from .config_resolver import RunnerConfigResolver class AgentRunnerDefaultConfigService: @@ -53,7 +53,7 @@ class AgentRunnerDefaultConfigService: if not isinstance(pipeline_config, dict): return False - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) if not runner_id: return False diff --git a/src/langbot/pkg/agent/runner/execution_context.py b/src/langbot/pkg/agent/runner/execution_context.py new file mode 100644 index 000000000..eaf781891 --- /dev/null +++ b/src/langbot/pkg/agent/runner/execution_context.py @@ -0,0 +1,319 @@ +"""Host-only Query compatibility views for AgentRunner tool execution.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import typing + +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.provider import message as provider_message +from langbot_plugin.api.entities.builtin.provider import session as provider_session + +from ...utils import constants +from .host_models import AgentEventEnvelope + + +HOST_BOX_SCOPE_VARIABLE = '_host_box_scope' +AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills' +MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments' +MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled' + + +def project_mcp_resource_config( + query: pipeline_query.Query, + runner_config: dict[str, typing.Any], +) -> dict[str, typing.Any]: + """Attach standard MCP resource settings to a Host execution Query.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + variables = {} + query.variables = variables + + attachments = runner_config.get('mcp-resources', []) + variables.setdefault( + MCP_RESOURCE_ATTACHMENTS_VARIABLE, + list(attachments) if isinstance(attachments, list) else [], + ) + variables.setdefault( + MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE, + runner_config.get('mcp-resource-agent-read-enabled', True) is True, + ) + return variables + + +async def build_mcp_resource_context_addition( + ap: typing.Any, + query: pipeline_query.Query, +) -> str: + """Build model-facing MCP context without mutating the canonical input.""" + tool_mgr = getattr(ap, 'tool_mgr', None) + if tool_mgr is None: + return '' + mcp_loader = getattr(tool_mgr, '__dict__', {}).get('mcp_tool_loader') + if mcp_loader is None: + return '' + + resource_context = await mcp_loader.build_resource_context_for_query(query) + if not resource_context: + return '' + + 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.' + ) + return addition + + +def append_mcp_resource_context_to_event(event: AgentEventEnvelope, addition: str) -> None: + """Append pinned context only to the run-scoped execution input.""" + if not addition: + return + has_text = event.input.text is not None + has_structured_content = bool(event.input.contents) + if has_text: + event.input.text += addition + if has_structured_content or not has_text: + if not _append_text_to_content(event.input.contents, addition): + event.input.contents.append(provider_message.ContentElement.from_text(addition.strip())) + + +def _append_text_to_content(content: typing.Any, addition: str) -> bool: + if isinstance(content, str): + return False + if not isinstance(content, list): + return False + for content_element in content: + if getattr(content_element, 'type', None) == 'text': + content_element.text = (content_element.text or '') + addition + return True + content.append(provider_message.ContentElement.from_text(addition.strip())) + return True + + +def prepare_execution_query( + query: pipeline_query.Query, + event: AgentEventEnvelope, + authorized_skill_names: list[str], +) -> pipeline_query.Query: + """Attach Host-owned execution metadata without changing Query identity.""" + variables = prepare_box_scope(query, event, preserve_existing=True) + variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names)) + return query + + +def prepare_box_scope( + query: pipeline_query.Query, + event: AgentEventEnvelope, + *, + preserve_existing: bool = False, +) -> dict[str, typing.Any]: + """Attach the Host Box scope before any Query-side file staging.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + variables = {} + query.variables = variables + + existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE) + if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip(): + return variables + + variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query) + return variables + + +def build_execution_query( + event: AgentEventEnvelope, + authorized_skill_names: list[str], +) -> pipeline_query.Query: + """Build the minimum Query view required by Host-owned tool loaders.""" + launcher_type, launcher_id, sender_id = _resolve_session_identity(event) + message_chain = _build_message_chain(event) + message_event = platform_events.MessageEvent( + type=event.source_event_type or event.event_type, + message_chain=message_chain, + time=float(event.event_time) if event.event_time is not None else None, + ) + session = provider_session.Session( + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + ) + + contents = copy.deepcopy(event.input.contents) + user_content: str | list[provider_message.ContentElement] + if contents: + user_content = contents + else: + user_content = event.input.text or '' + + query = pipeline_query.Query( + query_id=_synthetic_query_id(event.event_id), + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=sender_id, + message_event=message_event, + message_chain=message_chain, + bot_uuid=event.bot_id, + pipeline_uuid=None, + pipeline_config=None, + session=session, + messages=[], + user_message=provider_message.Message(role='user', content=user_content), + variables={}, + resp_messages=[], + ) + query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event) + query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names)) + return query + + +def build_host_box_scope( + event: AgentEventEnvelope, + *, + query: pipeline_query.Query | None = None, +) -> str | None: + """Return a stable Host scope for a Query session or event.""" + target_type, target_id = _resolve_box_target(event, query) + if target_type is None or target_id is None: + return None + + capabilities = event.delivery.platform_capabilities or {} + adapter_identity = None + if query is not None: + adapter = getattr(query, 'adapter', None) + if adapter is not None: + adapter_identity = adapter.__class__.__name__ + adapter_identity = _first_present( + adapter_identity, + capabilities.get('adapter'), + capabilities.get('source'), + event.source if query is None else None, + ) + + return json.dumps( + { + 'instance_id': _nonempty(constants.instance_id), + 'workspace_id': _nonempty(event.workspace_id), + 'bot_id': _nonempty(event.bot_id), + 'platform_adapter': adapter_identity, + 'target_type': target_type, + 'target_id': target_id, + 'thread_id': _nonempty(event.thread_id), + }, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + ) + + +def _resolve_session_identity( + event: AgentEventEnvelope, +) -> tuple[provider_session.LauncherTypes, str, str]: + subject_data = event.subject.data if event.subject is not None else {} + reply_target = event.delivery.reply_target or {} + + launcher_type_value = _first_present( + reply_target.get('target_type'), + subject_data.get('launcher_type'), + event.data.get('launcher_type'), + reply_target.get('launcher_type'), + ) + if launcher_type_value == provider_session.LauncherTypes.GROUP.value: + launcher_type_value = provider_session.LauncherTypes.GROUP.value + else: + launcher_type_value = provider_session.LauncherTypes.PERSON.value + + launcher_id = _first_nonempty( + reply_target.get('target_id'), + subject_data.get('launcher_id'), + event.data.get('launcher_id'), + reply_target.get('launcher_id'), + event.conversation_id, + event.subject.subject_id if event.subject is not None else None, + event.actor.actor_id if event.actor is not None else None, + event.event_id, + ) + sender_id = _first_nonempty( + subject_data.get('sender_id'), + event.data.get('sender_id'), + event.actor.actor_id if event.actor is not None else None, + launcher_id, + ) + + return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id + + +def _resolve_box_target( + event: AgentEventEnvelope, + query: pipeline_query.Query | None, +) -> tuple[str | None, str | None]: + if query is not None: + launcher_type = getattr(query, 'launcher_type', None) + if hasattr(launcher_type, 'value'): + launcher_type = launcher_type.value + launcher_id = getattr(query, 'launcher_id', None) + normalized_type = _nonempty(launcher_type) + normalized_id = _nonempty(launcher_id) + if normalized_type is not None and normalized_id is not None: + return normalized_type, normalized_id + + reply_target = event.delivery.reply_target or {} + target_type = _first_present( + reply_target.get('target_type'), + reply_target.get('launcher_type'), + ) + target_id = _first_present( + reply_target.get('target_id'), + reply_target.get('launcher_id'), + ) + if target_type is not None and target_id is not None: + return target_type, target_id + + conversation_id = _nonempty(event.conversation_id) + if conversation_id is not None: + return 'conversation', conversation_id + + event_id = _nonempty(event.event_id) + if event_id is not None: + return 'event', event_id + return None, None + + +def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain: + text = event.input.to_text() + if not text: + return platform_message.MessageChain([]) + return platform_message.MessageChain([platform_message.Plain(text=text)]) + + +def _synthetic_query_id(event_id: str) -> int: + digest = hashlib.sha256(event_id.encode('utf-8')).hexdigest() + return int(digest[:15], 16) or 1 + + +def _first_nonempty(*values: typing.Any) -> str: + normalized = _first_present(*values) + if normalized is not None: + return normalized + raise ValueError('Agent event does not contain a usable execution identity') + + +def _first_present(*values: typing.Any) -> str | None: + for value in values: + normalized = _nonempty(value) + if normalized is not None: + return normalized + return None + + +def _nonempty(value: typing.Any) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py index b435c3336..8f881878f 100644 --- a/src/langbot/pkg/agent/runner/host_models.py +++ b/src/langbot/pkg/agent/runner/host_models.py @@ -2,6 +2,7 @@ These are Host-internal models, not exposed to SDK. """ + from __future__ import annotations import typing @@ -73,7 +74,7 @@ class AgentEventEnvelope(pydantic.BaseModel): class BindingScope(pydantic.BaseModel): """Scope for agent binding.""" - scope_type: typing.Literal["agent", "bot", "workspace", "global"] = "agent" + scope_type: typing.Literal['agent', 'bot', 'workspace', 'global'] = 'agent' """Scope type.""" scope_id: str | None = None @@ -92,6 +93,12 @@ class ResourcePolicy(pydantic.BaseModel): allowed_tool_names: list[str] | None = None """Additional tool name grants. None means no additional tool grants.""" + allowed_tool_sources: dict[str, dict[str, str | None]] | None = None + """Host-resolved implementation identity for each allowed tool name.""" + + allow_all_tools: bool = False + """Whether all tools visible to the current Host scope are granted.""" + allowed_kb_uuids: list[str] | None = None """Additional knowledge base UUID grants. None means no additional KB grants.""" @@ -114,8 +121,8 @@ class StatePolicy(pydantic.BaseModel): enable_state: bool = True """Whether host-owned state is enabled.""" - state_scopes: list[typing.Literal["conversation", "actor", "subject", "runner"]] = ( - pydantic.Field(default_factory=lambda: ["conversation", "actor"]) + state_scopes: list[typing.Literal['conversation', 'actor', 'subject', 'runner']] = pydantic.Field( + default_factory=lambda: ['conversation', 'actor'] ) """Enabled state scopes.""" @@ -162,7 +169,7 @@ class AgentConfig(pydantic.BaseModel): delivery_policy: DeliveryPolicy = pydantic.Field(default_factory=DeliveryPolicy) """Delivery policy for this Agent.""" - event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"]) + event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received']) """Event types this Agent handles.""" enabled: bool = True @@ -185,7 +192,7 @@ class AgentBinding(pydantic.BaseModel): scope: BindingScope = pydantic.Field(default_factory=BindingScope) """Binding scope.""" - event_types: list[str] = pydantic.Field(default_factory=lambda: ["message.received"]) + event_types: list[str] = pydantic.Field(default_factory=lambda: ['message.received']) """Event types this binding handles.""" runner_id: str diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 008fc810a..c2743ecbf 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -12,6 +12,14 @@ from ...core import app from .binding_resolver import AgentBindingResolver from .context_builder import AgentRunContextBuilder, AgentRunContextPayload from .descriptor import AgentRunnerDescriptor +from .execution_context import ( + append_mcp_resource_context_to_event, + build_mcp_resource_context_addition, + build_execution_query, + prepare_box_scope, + prepare_execution_query, + project_mcp_resource_config, +) from .host_models import AgentBinding, AgentEventEnvelope from .invoker import AgentRunnerInvoker from .query_bridge import QueryRunBridge @@ -73,6 +81,17 @@ class AgentRunOrchestrator: runner_id = binding.runner_id descriptor = await self.registry.get(runner_id, bound_plugins) + execution_query = adapter_context.get('_query') if adapter_context else None + if execution_query is None: + execution_query = build_execution_query(event, []) + project_mcp_resource_config(execution_query, binding.runner_config) + + execution_event = event + resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query) + if resource_addition: + execution_event = event.model_copy(deep=True) + append_mcp_resource_context_to_event(execution_event, resource_addition) + resources = await self.resource_builder.build_resources_from_binding( event=event, binding=binding, @@ -80,7 +99,7 @@ class AgentRunOrchestrator: ) context = await self.context_builder.build_context_from_event( - event=event, + event=execution_event, binding=binding, descriptor=descriptor, resources=resources, @@ -88,19 +107,23 @@ class AgentRunOrchestrator: session_query_id = None if adapter_context: - query = adapter_context.get('_query') - if query is not None: + if execution_query is not None: skill_loader.restore_activated_skills_from_state( self.ap, - query, + execution_query, context.get('state', {}), ) session_query_id = adapter_context.get('query_id') - if query is not None or session_query_id is not None: + if execution_query is not None or session_query_id is not None: context['context']['available_apis']['prompt_get'] = True if 'params' in adapter_context: context['adapter']['extra']['params'] = adapter_context['params'] + authorized_skill_names = [ + str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name') + ] + prepare_execution_query(execution_query, event, authorized_skill_names) + state_context = build_state_context(event, binding, descriptor) run_id = context['run_id'] available_apis = context.get('context', {}).get('available_apis') @@ -152,6 +175,7 @@ class AgentRunOrchestrator: 'state_scopes': list(binding.state_policy.state_scopes), }, state_context=state_context, + execution_query=execution_query, ) event_log_id = await self.journal.write_event_log( @@ -309,6 +333,9 @@ class AgentRunOrchestrator: adapter_context = dict(plan.adapter_context) adapter_context['_query'] = query + # Inbound files and subsequent runner tools must share one Host scope. + prepare_box_scope(query, plan.event) + # Materialize inbound attachments into sandbox before running await self._materialize_inbound_attachments(query, plan.event) @@ -392,7 +419,13 @@ class AgentRunOrchestrator: if target_run_id is None: return False - steering_item = self._build_steering_item(event, target_run_id, descriptor.id) + execution_event = event + resource_addition = await build_mcp_resource_context_addition(self.ap, query) + if resource_addition: + execution_event = event.model_copy(deep=True) + append_mcp_resource_context_to_event(execution_event, resource_addition) + + steering_item = self._build_steering_item(execution_event, target_run_id, descriptor.id) if not await self._session_registry.enqueue_steering(target_run_id, steering_item): return False diff --git a/src/langbot/pkg/agent/runner/query_bridge.py b/src/langbot/pkg/agent/runner/query_bridge.py index 42e4601e3..609985b05 100644 --- a/src/langbot/pkg/agent/runner/query_bridge.py +++ b/src/langbot/pkg/agent/runner/query_bridge.py @@ -8,7 +8,7 @@ import typing from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from .binding_resolver import AgentBindingResolver -from .config_migration import ConfigMigration +from .config_resolver import RunnerConfigResolver from .errors import RunnerNotFoundError from .host_models import AgentBinding, AgentEventEnvelope from .query_entry_adapter import QueryEntryAdapter @@ -34,7 +34,7 @@ class QueryRunBridge: def build_plan(self, query: pipeline_query.Query) -> QueryRunPlan: """Build an event-first run plan from a Pipeline Query.""" - runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) if not runner_id: raise RunnerNotFoundError('no runner configured') @@ -53,4 +53,4 @@ class QueryRunBridge: def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None: """Resolve runner ID for telemetry/logging without full execution.""" - return ConfigMigration.resolve_runner_id(query.pipeline_config) + return RunnerConfigResolver.resolve_runner_id(query.pipeline_config) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index a5540bb64..b72d3949b 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -3,6 +3,7 @@ This adapter bridges the current Query entry point with the event-first Protocol v1 architecture without exposing Query internals to runners. """ + from __future__ import annotations import hashlib @@ -23,12 +24,13 @@ from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryCo from .host_models import ( AgentConfig, AgentEventEnvelope, - ResourcePolicy, StatePolicy, DeliveryPolicy, ) -from .config_migration import ConfigMigration +from .config_resolver import RunnerConfigResolver +from .resource_policy import ResourcePolicyProjector from . import events as runner_events +from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY class QueryEntryAdapter: @@ -83,7 +85,7 @@ class QueryEntryAdapter: event_id=event.event_id or str(query.query_id), event_type=event.event_type or runner_events.MESSAGE_RECEIVED, event_time=event.event_time, - source="host_adapter", + source='host_adapter', source_event_type=event.source_event_type, bot_id=query.bot_uuid, workspace_id=None, # Not available in Query @@ -105,21 +107,22 @@ class QueryEntryAdapter: ) -> AgentConfig: """Project the current Pipeline config container into target Agent config.""" pipeline_config = query.pipeline_config or {} - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) agent_id = getattr(query, 'pipeline_uuid', None) - # Build resource policy from current config - resource_policy = ResourcePolicy( - allowed_model_uuids=cls._extract_allowed_models(query), - allowed_tool_names=cls._extract_allowed_tools(query), - allowed_kb_uuids=cls._extract_allowed_kbs(query), - allowed_skill_names=cls._extract_allowed_skills(query), + resource_policy = ResourcePolicyProjector.from_runner_config( + runner_config, + resolved_model_uuids=cls._extract_allowed_models(query), + resolved_tool_names=cls._extract_allowed_tools(query), + resolved_tool_sources=cls._extract_allowed_tool_sources(query), + resolved_kb_uuids=cls._extract_allowed_kbs(query), + resolved_skill_names=cls._extract_allowed_skills(query), ) # Build state policy state_policy = StatePolicy( enable_state=True, - state_scopes=["conversation", "actor", "subject", "runner"], + state_scopes=['conversation', 'actor', 'subject', 'runner'], ) # Build delivery policy @@ -181,10 +184,7 @@ class QueryEntryAdapter: if isinstance(value, (list, tuple)): return all(cls.is_json_serializable(item) for item in value) if isinstance(value, dict): - return all( - isinstance(k, str) and cls.is_json_serializable(v) - for k, v in value.items() - ) + return all(isinstance(k, str) and cls.is_json_serializable(v) for k, v in value.items()) return False # Private helper methods @@ -228,7 +228,7 @@ class QueryEntryAdapter: event_id=cls._build_scoped_event_id(query, source_event_id, event_time), event_type=runner_events.MESSAGE_RECEIVED, event_time=event_time, - source="host_adapter", + source='host_adapter', source_event_type=source_event_type, data=event_data, ) @@ -345,7 +345,7 @@ class QueryEntryAdapter: actor_name = sender.get_name() if sender and hasattr(sender, 'get_name') else None return ActorContext( - actor_type="user", + actor_type='user', actor_id=str(actor_id) if actor_id is not None else None, actor_name=actor_name, metadata={}, @@ -371,13 +371,13 @@ class QueryEntryAdapter: launcher_type_value = getattr(launcher_type, 'value', launcher_type) return SubjectContext( - subject_type="message", + subject_type='message', subject_id=str(message_id or query_id or ''), data={ - "launcher_type": launcher_type_value, - "launcher_id": getattr(query, 'launcher_id', None), - "sender_id": str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None, - "bot_uuid": getattr(query, 'bot_uuid', None), + 'launcher_type': launcher_type_value, + 'launcher_id': getattr(query, 'launcher_id', None), + 'sender_id': str(getattr(query, 'sender_id', '')) if getattr(query, 'sender_id', None) else None, + 'bot_uuid': getattr(query, 'bot_uuid', None), }, ) @@ -467,31 +467,39 @@ class QueryEntryAdapter: if elem_type == 'image_url': image_url = elem.get('image_url') or {} - add_attachment({ - 'type': 'image', - 'source': 'url', - 'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url), - }) + add_attachment( + { + 'type': 'image', + 'source': 'url', + 'url': image_url.get('url') if isinstance(image_url, dict) else str(image_url), + } + ) elif elem_type == 'image_base64': - add_attachment({ - 'type': 'image', - 'source': 'base64', - 'content': elem.get('image_base64'), - }) + add_attachment( + { + 'type': 'image', + 'source': 'base64', + 'content': elem.get('image_base64'), + } + ) elif elem_type == 'file_url': - add_attachment({ - 'type': 'file', - 'source': 'url', - 'url': elem.get('file_url'), - 'name': elem.get('file_name'), - }) + add_attachment( + { + 'type': 'file', + 'source': 'url', + 'url': elem.get('file_url'), + 'name': elem.get('file_name'), + } + ) elif elem_type == 'file_base64': - add_attachment({ - 'type': 'file', - 'source': 'base64', - 'content': elem.get('file_base64'), - 'name': elem.get('file_name'), - }) + add_attachment( + { + 'type': 'file', + 'source': 'base64', + 'content': elem.get('file_base64'), + 'name': elem.get('file_name'), + } + ) message_chain = getattr(query, 'message_chain', None) if message_chain: @@ -505,30 +513,36 @@ class QueryEntryAdapter: image_id = component.image_id or None image_url = component.url or None image_base64 = component.base64 or None - add_attachment({ - 'type': 'image', - 'source': 'message_chain', - 'id': image_id, - 'url': image_url, - 'content': image_base64, - }) + add_attachment( + { + 'type': 'image', + 'source': 'message_chain', + 'id': image_id, + 'url': image_url, + 'content': image_base64, + } + ) elif isinstance(component, platform_message.File): - add_attachment({ - 'type': 'file', - 'source': 'message_chain', - 'id': component.id or None, - 'name': component.name or None, - 'url': component.url or None, - 'content': component.base64 or None, - }) + add_attachment( + { + 'type': 'file', + 'source': 'message_chain', + 'id': component.id or None, + 'name': component.name or None, + 'url': component.url or None, + 'content': component.base64 or None, + } + ) elif isinstance(component, platform_message.Voice): - add_attachment({ - 'type': 'voice', - 'source': 'message_chain', - 'id': component.voice_id or None, - 'url': component.url or None, - 'content': component.base64 or None, - }) + add_attachment( + { + 'type': 'voice', + 'source': 'message_chain', + 'id': component.voice_id or None, + 'url': component.url or None, + 'content': component.base64 or None, + } + ) return attachments @@ -557,9 +571,9 @@ class QueryEntryAdapter: """Build DeliveryContext from Query.""" message_chain = getattr(query, 'message_chain', None) return DeliveryContext( - surface="platform", + surface='platform', reply_target={ - "message_id": getattr(message_chain, 'message_id', None), + 'message_id': getattr(message_chain, 'message_id', None), }, supports_streaming=True, supports_edit=False, @@ -601,22 +615,24 @@ class QueryEntryAdapter: ) -> list[str] | None: """Extract allowed tool names from query.""" use_funcs = getattr(query, 'use_funcs', None) - if not use_funcs: + if use_funcs is None: return None try: - tool_names = [] - for func in use_funcs: - if isinstance(func, dict): - name = func.get('name') - elif hasattr(func, 'name'): - name = func.name - else: - continue - if name: - tool_names.append(name) - return tool_names if tool_names else None + return ResourcePolicyProjector.extract_tool_names(use_funcs) except (TypeError, AttributeError): + return [] + + @classmethod + def _extract_allowed_tool_sources( + cls, + query: pipeline_query.Query, + ) -> dict[str, typing.Any] | None: + """Extract the Host-frozen implementation for each allowed tool.""" + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): return None + refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY) + return refs if isinstance(refs, dict) else None @classmethod def _extract_allowed_kbs( @@ -627,10 +643,9 @@ class QueryEntryAdapter: variables = getattr(query, 'variables', None) if not variables: return None - kb_uuids = variables.get('_knowledge_base_uuids') - if kb_uuids: - return kb_uuids - return None + if '_knowledge_base_uuids' not in variables: + return None + return ResourcePolicyProjector.normalize_names(variables.get('_knowledge_base_uuids')) @classmethod def _extract_allowed_skills( diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py index 32238a1c6..0d1c10a3b 100644 --- a/src/langbot/pkg/agent/runner/resource_builder.py +++ b/src/langbot/pkg/agent/runner/resource_builder.py @@ -1,4 +1,5 @@ """Agent resource builder for constructing authorized resources.""" + from __future__ import annotations import typing @@ -15,6 +16,9 @@ from .context_builder import ( ) from . import config_schema from .host_models import AgentEventEnvelope, AgentBinding +from .resource_policy import ResourcePolicyProjector +from ...provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE +from ...provider.tools.toolmgr import ToolSourceRef class AgentResourceBuilder: @@ -66,18 +70,12 @@ class AgentResourceBuilder: manifest_perms = descriptor.permissions # Build each resource category - models = await self._build_models_from_binding( - manifest_perms, resource_policy, descriptor, runner_config - ) - tools = await self._build_tools_from_binding( - manifest_perms, resource_policy, descriptor - ) + models = await self._build_models_from_binding(manifest_perms, resource_policy, descriptor, runner_config) + tools = await self._build_tools_from_binding(manifest_perms, resource_policy, descriptor, runner_config) knowledge_bases = await self._build_knowledge_bases_from_binding( manifest_perms, resource_policy, descriptor, runner_config ) - skills = self._build_skills_from_binding( - resource_policy, descriptor - ) + skills = self._build_skills_from_binding(resource_policy, descriptor) storage = self._build_storage_from_binding(manifest_perms, binding) return { @@ -133,6 +131,7 @@ class AgentResourceBuilder: manifest_perms: typing.Any, resource_policy: typing.Any, descriptor: AgentRunnerDescriptor, + runner_config: dict[str, typing.Any], ) -> list[ToolResource]: """Build tools list from binding.""" tools: list[ToolResource] = [] @@ -143,8 +142,43 @@ class AgentResourceBuilder: if not config_schema.uses_host_tools(descriptor): return tools - # Get tool names from resource policy allowed_names = resource_policy.allowed_tool_names + allowed_sources = resource_policy.allowed_tool_sources + if resource_policy.allow_all_tools or allowed_sources is None: + get_catalog = getattr(getattr(self.ap, 'tool_mgr', None), 'get_resolved_tool_catalog', None) + if get_catalog is None: + return tools + try: + catalog = await get_catalog( + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to resolve visible Host tools: {e}') + return tools + + if not resource_policy.allow_all_tools: + selected_names = set(allowed_names or []) + catalog = [item for item in catalog if item.get('name') in selected_names] + allowed_names = ResourcePolicyProjector.extract_tool_names(catalog) + allowed_sources = { + item['name']: ref for item in catalog if (ref := self._catalog_source_ref(item)) is not None + } + + if runner_config.get('mcp-resource-agent-read-enabled', True) is not True: + denied_resource_tools = {MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE} + allowed_names = [ + name + for name in (allowed_names or []) + if not ( + name in denied_resource_tools + and allowed_sources + and (source_ref := allowed_sources.get(name)) is not None + and source_ref['source'] == 'mcp' + and source_ref.get('source_id') is None + ) + ] + tool_operations = [operation for operation in ('detail', 'call') if operation in tool_perms] # Prefill full tool schema (best-effort) so runners can build LLM tool @@ -153,20 +187,39 @@ class AgentResourceBuilder: get_tool_schema = getattr(getattr(self.ap, 'tool_mgr', None), 'get_tool_schema', None) if allowed_names: for tool_name in allowed_names: + source_ref = allowed_sources.get(tool_name) if allowed_sources else None + if source_ref is None: + self.ap.logger.warning(f'Tool {tool_name} is not authorized because its Host source is unresolved') + continue if get_tool_schema is not None: - description, parameters = await get_tool_schema(tool_name) + description, parameters = await get_tool_schema(tool_name, source_ref=source_ref) else: description, parameters = None, None - tools.append({ - 'tool_name': tool_name, - 'tool_type': None, - 'description': description, - 'operations': tool_operations, - 'parameters': parameters, - }) + tools.append( + { + 'tool_name': tool_name, + 'tool_type': source_ref['source'], + 'description': description, + 'operations': tool_operations, + 'parameters': parameters, + 'source': source_ref['source'], + 'source_id': source_ref.get('source_id'), + } + ) return tools + @staticmethod + def _catalog_source_ref(item: dict[str, typing.Any]) -> ToolSourceRef | None: + source = item.get('source') + if not isinstance(source, str) or not source: + return None + source_id = item.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + async def _build_knowledge_bases_from_binding( self, manifest_perms: typing.Any, @@ -196,12 +249,16 @@ class AgentResourceBuilder: try: kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid) if kb: - kb_resources.append({ - 'kb_id': kb_uuid, - 'kb_name': kb.get_name(), - 'kb_type': kb.knowledge_base_entity.kb_type if hasattr(kb.knowledge_base_entity, 'kb_type') else None, - 'operations': kb_operations, - }) + kb_resources.append( + { + 'kb_id': kb_uuid, + 'kb_name': kb.get_name(), + 'kb_type': kb.knowledge_base_entity.kb_type + if hasattr(kb.knowledge_base_entity, 'kb_type') + else None, + 'operations': kb_operations, + } + ) except Exception as e: self.ap.logger.warning(f'Failed to build knowledge base resource {kb_uuid}: {e}') @@ -233,11 +290,13 @@ class AgentResourceBuilder: skills: list[SkillResource] = [] for skill_name in names: skill_data = loaded_skills.get(skill_name) or {} - skills.append({ - 'skill_name': skill_name, - 'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name, - 'description': skill_data.get('description') or None, - }) + skills.append( + { + 'skill_name': skill_name, + 'display_name': skill_data.get('display_name') or skill_data.get('name') or skill_name, + 'description': skill_data.get('description') or None, + } + ) return skills def _build_storage_from_binding( @@ -285,12 +344,16 @@ class AgentResourceBuilder: try: model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) if model and model.model_entity: - models.append({ - 'model_id': model_uuid, - 'model_type': getattr(model.model_entity, 'model_type', None), - 'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None, - 'operations': operations, - }) + models.append( + { + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', None), + 'provider': getattr(model.provider_entity, 'name', None) + if hasattr(model, 'provider_entity') + else None, + 'operations': operations, + } + ) seen_model_ids.add(model_uuid) except Exception as e: self.ap.logger.warning(f'Failed to build LLM model resource {model_uuid}: {e}') @@ -308,12 +371,16 @@ class AgentResourceBuilder: try: model = await self.ap.model_mgr.get_rerank_model_by_uuid(model_uuid) if model and model.model_entity: - models.append({ - 'model_id': model_uuid, - 'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank', - 'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None, - 'operations': ['rerank'], - }) + models.append( + { + 'model_id': model_uuid, + 'model_type': getattr(model.model_entity, 'model_type', 'rerank') or 'rerank', + 'provider': getattr(model.provider_entity, 'name', None) + if hasattr(model, 'provider_entity') + else None, + 'operations': ['rerank'], + } + ) seen_model_ids.add(model_uuid) except Exception as e: self.ap.logger.warning(f'Failed to build rerank model resource {model_uuid}: {e}') diff --git a/src/langbot/pkg/agent/runner/resource_policy.py b/src/langbot/pkg/agent/runner/resource_policy.py new file mode 100644 index 000000000..92d934566 --- /dev/null +++ b/src/langbot/pkg/agent/runner/resource_policy.py @@ -0,0 +1,146 @@ +"""Project AgentRunner configuration into Host resource policy.""" + +from __future__ import annotations + +import collections.abc +import typing + +from .host_models import ResourcePolicy + + +class ResourcePolicyProjector: + """Build one generic resource policy for Pipeline and Agent bindings.""" + + @classmethod + def from_runner_config( + cls, + runner_config: dict[str, typing.Any] | None, + *, + resolved_model_uuids: collections.abc.Iterable[typing.Any] | None = None, + resolved_tool_names: collections.abc.Iterable[typing.Any] | None = None, + resolved_tool_sources: typing.Mapping[str, typing.Any] | None = None, + resolved_kb_uuids: collections.abc.Iterable[typing.Any] | None = None, + resolved_skill_names: collections.abc.Iterable[typing.Any] | None = None, + ) -> ResourcePolicy: + """Project standard resource fields without depending on a runner ID. + + Resolved values are supplied by entry paths that already narrowed the + available resources, such as Pipeline preprocessing. Independent Agent + bindings omit them so the Host can resolve an explicit all-tools grant + against the live tool catalog when the run starts. + """ + config = runner_config if isinstance(runner_config, dict) else {} + selected_tool_names = cls.normalize_names(config.get('tools')) + enable_all_tools = config.get('enable-all-tools', True) is True + + if resolved_tool_names is not None: + available_tool_names = cls.normalize_names(resolved_tool_names) + if enable_all_tools: + allowed_tool_names = available_tool_names + else: + selected = set(selected_tool_names) + allowed_tool_names = [name for name in available_tool_names if name in selected] + allow_all_tools = False + elif enable_all_tools: + allowed_tool_names = None + allow_all_tools = True + else: + allowed_tool_names = selected_tool_names + allow_all_tools = False + + allowed_tool_sources = cls.normalize_tool_sources(resolved_tool_sources) + if allowed_tool_sources is not None: + allowed = set(allowed_tool_names or []) + allowed_tool_sources = {name: ref for name, ref in allowed_tool_sources.items() if name in allowed} + + if resolved_kb_uuids is not None: + allowed_kb_uuids = cls.normalize_names(resolved_kb_uuids) + elif 'knowledge-bases' in config: + allowed_kb_uuids = cls.normalize_names(config.get('knowledge-bases')) + else: + allowed_kb_uuids = None + + return ResourcePolicy( + allowed_model_uuids=cls.normalize_optional_names(resolved_model_uuids), + allowed_tool_names=allowed_tool_names, + allowed_tool_sources=allowed_tool_sources, + allow_all_tools=allow_all_tools, + allowed_kb_uuids=allowed_kb_uuids, + allowed_skill_names=cls.normalize_optional_names(resolved_skill_names), + ) + + @staticmethod + def normalize_names(values: typing.Any) -> list[str]: + """Return unique, non-empty string identifiers in source order.""" + if not isinstance(values, collections.abc.Iterable) or isinstance(values, (str, bytes, dict)): + return [] + + names: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str) or not value or value in seen: + continue + seen.add(value) + names.append(value) + return names + + @classmethod + def normalize_optional_names( + cls, + values: collections.abc.Iterable[typing.Any] | None, + ) -> list[str] | None: + if values is None: + return None + return cls.normalize_names(values) + + @classmethod + def extract_tool_names(cls, tools: collections.abc.Iterable[typing.Any] | None) -> list[str]: + """Extract tool identifiers from dictionary and SDK object shapes.""" + if tools is None: + return [] + + names: list[str] = [] + for tool in tools: + name = tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None) + if isinstance(name, str): + names.append(name) + return cls.normalize_names(names) + + @staticmethod + def normalize_tool_sources( + values: typing.Mapping[str, typing.Any] | None, + ) -> dict[str, dict[str, str | None]] | None: + if values is None: + return None + + normalized: dict[str, dict[str, str | None]] = {} + for name, value in values.items(): + if not isinstance(name, str) or not name or not isinstance(value, collections.abc.Mapping): + continue + source = value.get('source') + if not isinstance(source, str) or not source: + continue + source_id = value.get('source_id') + normalized[name] = { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + return normalized + + @classmethod + def filter_tools( + cls, + tools: collections.abc.Iterable[typing.Any], + policy: ResourcePolicy, + ) -> list[typing.Any]: + """Apply a projected policy to an already scoped tool collection.""" + tool_list = list(tools) + if policy.allow_all_tools: + return tool_list + + allowed_names = set(policy.allowed_tool_names or []) + return [ + tool + for tool in tool_list + if (tool.get('name') if isinstance(tool, dict) else getattr(tool, 'name', None)) in allowed_names + ] diff --git a/src/langbot/pkg/agent/runner/run_journal.py b/src/langbot/pkg/agent/runner/run_journal.py index fdfdfed16..2c478c84e 100644 --- a/src/langbot/pkg/agent/runner/run_journal.py +++ b/src/langbot/pkg/agent/runner/run_journal.py @@ -304,13 +304,10 @@ class AgentRunJournal: for item in items: event = item.get('event') if isinstance(item.get('event'), dict) else {} - input_data = item.get('input') if isinstance(item.get('input'), dict) else {} conversation = item.get('conversation') if isinstance(item.get('conversation'), dict) else {} actor = item.get('actor') if isinstance(item.get('actor'), dict) else {} subject = item.get('subject') if isinstance(item.get('subject'), dict) else {} - text = input_data.get('text') - input_summary = text[:1000] if isinstance(text, str) and text else 'Unconsumed steering input dropped' event_time = None raw_event_time = event.get('event_time') if raw_event_time: @@ -335,12 +332,7 @@ class AgentRunJournal: actor_name=actor.get('actor_name'), subject_type=subject.get('subject_type'), subject_id=subject.get('subject_id'), - input_summary=input_summary, - input_json={ - 'text': text, - 'contents': self._sanitize_contents(input_data.get('contents') or []), - 'attachments': self._sanitize_attachments(input_data.get('attachments') or []), - }, + input_summary='Unconsumed steering input dropped', run_id=run_id, runner_id=runner_id, event_time=event_time, diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index 8179ef90f..7d5df93f6 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -1,4 +1,5 @@ """Agent run session registry for proxy action permission validation.""" + from __future__ import annotations import asyncio @@ -7,7 +8,10 @@ import typing import time import threading +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query + from .context_builder import AgentResources +from ...provider.tools.toolmgr import ToolSourceRef MAX_STEERING_QUEUE_ITEMS = 100 @@ -22,6 +26,7 @@ DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { class AgentRunSessionStatus(typing.TypedDict): """Status tracking for agent run session.""" + started_at: int last_activity_at: int @@ -58,13 +63,16 @@ class AgentRunSession(typing.TypedDict): run_id: Unique run identifier (UUID from AgentRunContext) runner_id: Runner descriptor ID (plugin:author/name/runner) query_id: Host entry query ID, only present for query-based adapters + execution_query: Host-only Query used by providers and tool loaders plugin_identity: Plugin identifier (author/name) of the runner authorization: Run-scoped authorization snapshot; runtime auth truth status: Session status tracking """ + run_id: str runner_id: str query_id: int | None + execution_query: pipeline_query.Query | None plugin_identity: str # author/name authorization: RunAuthorizationSnapshot status: AgentRunSessionStatus @@ -104,6 +112,7 @@ class AgentRunSessionRegistry: available_apis: dict[str, bool] | None = None, state_policy: dict[str, typing.Any] | None = None, state_context: dict[str, typing.Any] | None = None, + execution_query: pipeline_query.Query | None = None, ) -> None: """Register a new agent run session. @@ -120,6 +129,7 @@ class AgentRunSessionRegistry: available_apis: Run-scoped pull APIs exposed in AgentRunContext state_policy: State policy from binding (enable_state, state_scopes) state_context: Context for state API (scope_keys, binding_identity, etc.) + execution_query: Host-only Query used for provider and tool execution """ if not isinstance(plugin_identity, str) or not plugin_identity.strip(): raise ValueError('plugin_identity is required for agent run sessions') @@ -153,6 +163,7 @@ class AgentRunSessionRegistry: 'run_id': run_id, 'runner_id': runner_id, 'query_id': query_id, + 'execution_query': execution_query, 'plugin_identity': plugin_identity, 'authorization': authorization, 'status': { @@ -371,6 +382,26 @@ class AgentRunSessionRegistry: return False + @staticmethod + def get_tool_source_ref( + session: AgentRunSession, + tool_name: str, + ) -> ToolSourceRef | None: + """Return the implementation identity frozen in this run's grant.""" + resources = session['authorization']['resources'] + for tool in resources.get('tools', []): + if tool.get('tool_name') != tool_name: + continue + source = tool.get('source') + if not isinstance(source, str) or not source: + return None + source_id = tool.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + return None + async def list_active_runs(self) -> list[AgentRunSession]: """List all active run sessions. diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py index 9cdbbd7f3..ad8375cb8 100644 --- a/src/langbot/pkg/api/http/controller/groups/agents.py +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -16,7 +16,10 @@ class AgentsRouterGroup(group.RouterGroup): return self.success(data={'agents': await self.ap.agent_service.get_agents(sort_by, sort_order)}) json_data = await quart.request.json - created = await self.ap.agent_service.create_agent(json_data) + try: + created = await self.ap.agent_service.create_agent(json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success(data=created) @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @@ -33,7 +36,10 @@ class AgentsRouterGroup(group.RouterGroup): if quart.request.method == 'PUT': json_data = await quart.request.json - await self.ap.agent_service.update_agent(agent_uuid, json_data) + try: + await self.ap.agent_service.update_agent(agent_uuid, json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success() await self.ap.agent_service.delete_agent(agent_uuid) diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index c8b52127c..f45c89527 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -3,6 +3,7 @@ from __future__ import annotations import quart from ... import group +from ......pipeline.extension_preferences import normalize_extension_preferences @group.group_class('pipelines', '/api/v1/pipelines') @@ -19,7 +20,10 @@ class PipelinesRouterGroup(group.RouterGroup): elif quart.request.method == 'POST': json_data = await quart.request.json - pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + try: + pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success(data={'uuid': pipeline_uuid}) @@ -41,7 +45,10 @@ class PipelinesRouterGroup(group.RouterGroup): elif quart.request.method == 'PUT': json_data = await quart.request.json - await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + try: + await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success() elif quart.request.method == 'DELETE': @@ -81,21 +88,19 @@ class PipelinesRouterGroup(group.RouterGroup): self.ap.logger.warning('Unable to list skills for pipeline extensions: %s', exc) available_skills = [] - extensions_prefs = pipeline.get('extensions_preferences', {}) + extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) return self.success( data={ - 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), - 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), - 'enable_all_skills': extensions_prefs.get('enable_all_skills', True), - 'bound_plugins': extensions_prefs.get('plugins', []), + 'enable_all_plugins': extensions_prefs['enable_all_plugins'], + 'enable_all_mcp_servers': extensions_prefs['enable_all_mcp_servers'], + 'enable_all_skills': extensions_prefs['enable_all_skills'], + 'bound_plugins': extensions_prefs['plugins'], 'available_plugins': plugins, - 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), + 'bound_mcp_servers': extensions_prefs['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', []), + 'bound_mcp_resources': extensions_prefs['mcp_resources'], + 'mcp_resource_agent_read_enabled': extensions_prefs['mcp_resource_agent_read_enabled'], + 'bound_skills': extensions_prefs['skills'], 'available_skills': available_skills, } ) @@ -111,16 +116,41 @@ class PipelinesRouterGroup(group.RouterGroup): 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, - bound_plugins, - bound_mcp_servers, - enable_all_plugins, - 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, - ) + extension_flags = { + 'enable_all_plugins': enable_all_plugins, + 'enable_all_mcp_servers': enable_all_mcp_servers, + 'enable_all_skills': enable_all_skills, + } + for field, value in extension_flags.items(): + if field in json_data and not isinstance(value, bool): + return self.http_status( + 400, + -1, + f"Pipeline extension field '{field}' must be a boolean", + ) + + if 'mcp_resource_agent_read_enabled' in json_data and not isinstance( + mcp_resource_agent_read_enabled, bool + ): + return self.http_status( + 400, + -1, + "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean", + ) + + try: + await self.ap.pipeline_service.update_pipeline_extensions( + pipeline_uuid, + bound_plugins, + bound_mcp_servers, + enable_all_plugins, + 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, + ) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index e51399a8f..b98062484 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -3,59 +3,61 @@ from __future__ import annotations import quart from ... import group +from ......pipeline.extension_preferences import normalize_extension_preferences @group.group_class('tools', '/api/v1/tools') class ToolsRouterGroup(group.RouterGroup): + async def _get_scoped_tool_catalog(self) -> list[dict] | None: + 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 + + if pipeline_uuid: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return None + + extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences')) + if not extensions_prefs['enable_all_plugins']: + bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']] + if not extensions_prefs['enable_all_mcp_servers']: + bound_mcp_servers = extensions_prefs['mcp_servers'] + + return await self.ap.tool_mgr.get_resolved_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + ) + async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """获取所有可用工具列表""" - 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 + catalog = await self._get_scoped_tool_catalog() + if catalog is None: + return self.http_status(404, -1, 'pipeline not found') + return self.success(data={'tools': catalog}) - 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') - - 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('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(tool_name: str) -> str: """获取特定工具详情""" - tools = await self.ap.tool_mgr.get_all_tools() + catalog = await self._get_scoped_tool_catalog() + if catalog is None: + return self.http_status(404, -1, 'pipeline not found') - for tool in tools: - if tool.name == tool_name: + for tool in catalog: + if tool.get('name') == tool_name: return self.success( data={ 'tool': { - 'name': tool.name, - 'description': tool.description, - 'human_desc': tool.human_desc, - 'parameters': tool.parameters, + 'name': tool['name'], + 'description': tool.get('description') or '', + 'human_desc': tool.get('human_desc') or '', + 'parameters': tool.get('parameters') or {}, + 'source': tool.get('source'), + 'source_name': tool.get('source_name'), + 'source_id': tool.get('source_id'), } } ) diff --git a/src/langbot/pkg/api/http/service/agent.py b/src/langbot/pkg/api/http/service/agent.py index a3fae955e..9f8accd08 100644 --- a/src/langbot/pkg/api/http/service/agent.py +++ b/src/langbot/pkg/api/http/service/agent.py @@ -7,6 +7,7 @@ import typing import sqlalchemy from ....core import app +from ....agent.runner.config_resolver import RunnerConfigResolver from ....entity.persistence import agent as persistence_agent @@ -82,8 +83,8 @@ class AgentService: if kind != AGENT_KIND_AGENT: raise ValueError(f'Unsupported agent kind: {kind}') - config = agent_data.get('config') or await self._get_default_agent_config() - runner_id = self._resolve_runner_id(config) + config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config() + config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config) new_uuid = str(uuid.uuid4()) values = { 'uuid': new_uuid, @@ -91,7 +92,7 @@ class AgentService: 'description': agent_data.get('description') or '', 'emoji': agent_data.get('emoji') or '🤖', 'kind': AGENT_KIND_AGENT, - 'component_ref': agent_data.get('component_ref') or runner_id, + 'component_ref': runner_id, 'config': config, 'enabled': agent_data.get('enabled', True), 'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS, @@ -109,12 +110,14 @@ class AgentService: return update_data = agent_data.copy() - for protected_field in ('uuid', 'kind', 'created_at', 'updated_at', 'capability'): + for protected_field in ('uuid', 'kind', 'component_ref', 'created_at', 'updated_at', 'capability'): update_data.pop(protected_field, None) if 'config' in update_data: - update_data['component_ref'] = update_data.get('component_ref') or self._resolve_runner_id( - update_data['config'] - ) + config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config']) + update_data['config'] = config + else: + _, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config) + update_data['component_ref'] = runner_id if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']: update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS @@ -169,15 +172,6 @@ class AgentService: }, } - @staticmethod - def _resolve_runner_id(config: dict[str, typing.Any]) -> str | None: - runner = config.get('runner') if isinstance(config, dict) else None - if isinstance(runner, dict): - runner_id = runner.get('id') - if runner_id: - return runner_id - return None - def _agent_to_product_item( self, agent: persistence_agent.Agent, diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index a00882857..51523a727 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -6,7 +6,12 @@ import sqlalchemy import typing from ....core import app +from ....agent.runner.config_resolver import RunnerConfigResolver from ....entity.persistence import pipeline as persistence_pipeline +from ....pipeline.extension_preferences import ( + normalize_extension_preferences, + validate_extension_preferences, +) default_stage_order = [ @@ -163,6 +168,11 @@ class PipelineService: return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: + if 'extensions_preferences' in pipeline_data: + self._validate_extension_preferences(pipeline_data['extensions_preferences']) + if 'config' in pipeline_data: + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) + # Check limitation limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) max_pipelines = limitation.get('max_pipelines', -1) @@ -177,6 +187,7 @@ class PipelineService: pipeline_data['is_default'] = default pipeline_data['config'] = await self.get_default_pipeline_config() + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) # Ensure extensions_preferences is set with enable_all_plugins and enable_all_mcp_servers=True by default if 'extensions_preferences' not in pipeline_data: @@ -203,6 +214,10 @@ class PipelineService: pipeline_data = pipeline_data.copy() for protected_field in ('uuid', 'for_version', 'stages', 'is_default'): pipeline_data.pop(protected_field, None) + if 'config' in pipeline_data: + RunnerConfigResolver.validate_pipeline_config(pipeline_data['config']) + if 'extensions_preferences' in pipeline_data: + self._validate_extension_preferences(pipeline_data['extensions_preferences']) await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) @@ -259,18 +274,7 @@ class PipelineService: 'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(), 'config': original_pipeline.config.copy() if original_pipeline.config else {}, 'is_default': False, - 'extensions_preferences': ( - original_pipeline.extensions_preferences.copy() - if original_pipeline.extensions_preferences - else { - 'enable_all_plugins': True, - 'enable_all_mcp_servers': True, - 'plugins': [], - 'mcp_servers': [], - 'mcp_resources': [], - 'mcp_resource_agent_read_enabled': True, - } - ), + 'extensions_preferences': normalize_extension_preferences(original_pipeline.extensions_preferences), } # Insert the new pipeline @@ -297,6 +301,36 @@ class PipelineService: mcp_resource_agent_read_enabled: bool | None = None, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" + extension_updates: dict[str, typing.Any] = { + 'enable_all_plugins': enable_all_plugins, + 'enable_all_mcp_servers': enable_all_mcp_servers, + 'enable_all_skills': enable_all_skills, + 'plugins': bound_plugins, + } + if bound_mcp_servers is not None: + extension_updates['mcp_servers'] = bound_mcp_servers + if bound_skills is not None: + extension_updates['skills'] = bound_skills + if bound_mcp_resources is not None: + extension_updates['mcp_resources'] = bound_mcp_resources + RunnerConfigResolver.validate_mcp_resource_attachments( + bound_mcp_resources, + context='Pipeline extension', + field_name='bound_mcp_resources', + ) + if mcp_resource_agent_read_enabled is not None: + extension_updates['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled + self._validate_extension_preferences( + extension_updates, + context='Pipeline extension', + field_aliases={ + 'plugins': 'bound_plugins', + 'mcp_servers': 'bound_mcp_servers', + 'skills': 'bound_skills', + 'mcp_resources': 'bound_mcp_resources', + }, + ) + # Get current pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( @@ -309,7 +343,7 @@ class PipelineService: raise ValueError(f'Pipeline {pipeline_uuid} not found') # Update extensions_preferences - extensions_preferences = pipeline.extensions_preferences or {} + extensions_preferences = normalize_extension_preferences(pipeline.extensions_preferences) extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers extensions_preferences['enable_all_skills'] = enable_all_skills @@ -333,3 +367,22 @@ class PipelineService: await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid) pipeline = await self.get_pipeline(pipeline_uuid) await self.ap.pipeline_mgr.load_pipeline(pipeline) + + @staticmethod + def _validate_extension_preferences( + value: typing.Any, + *, + context: str = 'Pipeline extensions_preferences', + field_aliases: typing.Mapping[str, str] | None = None, + ) -> dict[str, typing.Any]: + validated = validate_extension_preferences( + value, + context=context, + field_aliases=field_aliases, + ) + RunnerConfigResolver.validate_mcp_resource_attachments( + validated.get('mcp_resources'), + context=context, + field_name='mcp_resources', + ) + return validated diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index e7f22df14..389c61990 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -4,6 +4,7 @@ import asyncio import collections import datetime as _dt import enum +import hashlib import json import os from typing import TYPE_CHECKING @@ -13,6 +14,7 @@ import pydantic from langbot_plugin.box.client import BoxRuntimeClient from .connector import BoxRuntimeConnector, _get_box_config from ..telemetry import features as telemetry_features +from ..utils import constants from langbot_plugin.box.errors import BoxError, BoxValidationError from langbot_plugin.box.models import ( BUILTIN_PROFILES, @@ -27,6 +29,8 @@ _INT_ADAPTER = pydantic.TypeAdapter(int) _UTC = _dt.timezone.utc _MAX_RECENT_ERRORS = 50 _MIB = 1024 * 1024 +_HOST_BOX_SCOPE_VARIABLE = '_host_box_scope' +_BOX_SESSION_ID_PREFIX = 'lb-box-' def _is_path_under(path: str, root: str) -> bool: @@ -224,45 +228,53 @@ class BoxService: return self._serialize_result(result) def resolve_box_session_id(self, query: pipeline_query.Query) -> str: - """Resolve the Box session_id from the pipeline's template and query variables. + """Resolve a Host-owned Box session ID for the current conversation.""" + if query is None: + raise BoxValidationError('Box execution requires a Host session context.') - When ``system.limitation.force_box_session_id_template`` is set to a - non-empty value, that template overrides whatever the pipeline - configured. This is the authoritative SaaS guard: it runs on every - ``exec`` call, so a tenant cannot escape a single shared sandbox even - by editing the pipeline config directly through the API (which only - gates the web UI). - """ - forced_template = self._forced_box_session_id_template() - if forced_template: - template = forced_template - else: - template = '{launcher_type}_{launcher_id}' - pipeline_config = query.pipeline_config or {} - ai_config = pipeline_config.get('ai', {}) if isinstance(pipeline_config, dict) else {} - runner_selector = ai_config.get('runner', {}) if isinstance(ai_config, dict) else {} - runner_id = runner_selector.get('id') if isinstance(runner_selector, dict) else None - runner_configs = ai_config.get('runner_config', {}) if isinstance(ai_config, dict) else {} - runner_config = runner_configs.get(runner_id, {}) if isinstance(runner_configs, dict) else {} - configured_template = ( - runner_config.get('box-session-id-template') if isinstance(runner_config, dict) else None - ) - if isinstance(configured_template, str) and configured_template: - template = configured_template - variables = dict(query.variables or {}) + variables = getattr(query, 'variables', None) + if isinstance(variables, dict) and _HOST_BOX_SCOPE_VARIABLE in variables: + private_scope = variables[_HOST_BOX_SCOPE_VARIABLE] + if not isinstance(private_scope, str) or not private_scope.strip(): + raise BoxValidationError('Box execution requires a Host conversation scope.') + return self._hash_box_session_scope(f'host:{private_scope}') + + session = getattr(query, 'session', None) launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + + if launcher_type is None: + launcher_type = getattr(session, 'launcher_type', None) + if launcher_id is None: + launcher_id = getattr(session, 'launcher_id', None) if hasattr(launcher_type, 'value'): launcher_type = launcher_type.value - launcher_id = getattr(query, 'launcher_id', None) - sender_id = getattr(query, 'sender_id', None) - query_id = getattr(query, 'query_id', None) - variables.setdefault('query_id', str(query_id or 'unknown')) - variables.setdefault('launcher_type', str(launcher_type or 'query')) - variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown')) - variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown')) - variables.setdefault('global', 'global') - return template.format_map(collections.defaultdict(lambda: 'unknown', variables)) + if launcher_type is None or launcher_id is None or not str(launcher_id).strip(): + raise BoxValidationError('Box execution requires a Host session context.') + + adapter = getattr(query, 'adapter', None) + adapter_identity = adapter.__class__.__name__ if adapter is not None else None + scope = json.dumps( + { + 'instance_id': str(constants.instance_id or '') or None, + 'workspace_id': None, + 'bot_id': getattr(query, 'bot_uuid', None), + 'platform_adapter': adapter_identity, + 'target_type': str(launcher_type), + 'target_id': str(launcher_id), + 'thread_id': None, + }, + ensure_ascii=False, + sort_keys=True, + separators=(',', ':'), + ) + return self._hash_box_session_scope(f'host:{scope}') + + @staticmethod + def _hash_box_session_scope(scope: str) -> str: + digest = hashlib.sha256(scope.encode('utf-8')).hexdigest() + return f'{_BOX_SESSION_ID_PREFIX}{digest}' def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]: """Build extra_mounts entries for all pipeline-bound skills. @@ -1128,20 +1140,6 @@ class BoxService: raw = str(self._local_config().get('image', '') or '').strip() return raw or None - def _forced_box_session_id_template(self) -> str: - """Return the SaaS-forced sandbox-scope template, or '' when unset. - - Read from ``system.limitation.force_box_session_id_template``. A - non-empty value pins every pipeline to a single sandbox scope - (e.g. ``'{global}'``) and cannot be overridden per-pipeline. - """ - limitation = ( - (self.ap.instance_config.data or {}).get('system', {}).get('limitation', {}) - if getattr(self.ap, 'instance_config', None) is not None - else {} - ) - return str(limitation.get('force_box_session_id_template', '') or '').strip() - def _load_workspace_quota_mb(self) -> int | None: raw_value = self._local_config().get('workspace_quota_mb') if raw_value in (None, ''): @@ -1311,42 +1309,6 @@ class BoxService: def get_recent_errors(self) -> list[dict]: return list(self._recent_errors) - def get_system_guidance(self, query_id=None) -> str: - """Return LLM system-prompt guidance for the exec tool. - - All execution-specific prompt text is kept here so that callers - (e.g. LocalAgentRunner) stay free of box domain knowledge. - - ``query_id`` is the current turn's pipeline query id. When provided, - the guidance ALWAYS advertises the per-query outbox path so the agent - knows how to deliver generated files back to the user — even on turns - where the user sent no inbound attachment (e.g. "generate a QR code"), - which is exactly when the inbound-attachment note never fires. Outbound - collection in the wrapper runs on every turn regardless of inbound - files, so without this the file would be produced and silently dropped. - """ - guidance = ( - 'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, ' - 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' - 'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec ' - 'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation ' - 'details, do not include the generated script in the final answer; return the result and a brief explanation only.' - ) - if self.default_workspace: - guidance += ( - ' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or ' - 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' - 'user for directory parameters unless they explicitly need a different directory.' - ) - if query_id is not None: - outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}' - guidance += ( - f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, ' - f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be ' - 'delivered to the user automatically; do not paste file contents or base64 into your reply.' - ) - return guidance - async def get_status(self) -> dict: if not self._available: return { diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 6fc890f10..03a8a0fd8 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -110,44 +110,6 @@ class LoadConfigStage(stage.BootingStage): async def run(self, ap: app.Application): """Load config file""" - # # ======= deprecated ======= - # if os.path.exists('data/config/command.json'): - # ap.command_cfg = await config.load_json_config( - # 'data/config/command.json', - # 'templates/legacy/command.json', - # completion=False, - # ) - - # if os.path.exists('data/config/pipeline.json'): - # ap.pipeline_cfg = await config.load_json_config( - # 'data/config/pipeline.json', - # 'templates/legacy/pipeline.json', - # completion=False, - # ) - - # if os.path.exists('data/config/platform.json'): - # ap.platform_cfg = await config.load_json_config( - # 'data/config/platform.json', - # 'templates/legacy/platform.json', - # completion=False, - # ) - - # if os.path.exists('data/config/provider.json'): - # ap.provider_cfg = await config.load_json_config( - # 'data/config/provider.json', - # 'templates/legacy/provider.json', - # completion=False, - # ) - - # if os.path.exists('data/config/system.json'): - # ap.system_cfg = await config.load_json_config( - # 'data/config/system.json', - # 'templates/legacy/system.json', - # completion=False, - # ) - - # # ======= deprecated ======= - ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False) # Apply environment variable overrides to data/config.yaml diff --git a/src/langbot/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py index ac3b4602f..3b2cd0996 100644 --- a/src/langbot/pkg/entity/persistence/metadata.py +++ b/src/langbot/pkg/entity/persistence/metadata.py @@ -1,15 +1,6 @@ import sqlalchemy from .base import Base -from ...utils import constants - - -initial_metadata = [ - { - 'key': 'database_version', - 'value': str(constants.required_database_version), - }, -] class Metadata(Base): diff --git a/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py index 929356e00..b72188c7c 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py +++ b/src/langbot/pkg/persistence/alembic/versions/0001_baseline.py @@ -1,7 +1,7 @@ -"""baseline: stamp existing schema (db version 25) +"""baseline: stamp the supported 4.x schema This is a no-op migration that marks the starting point for Alembic. -All tables already exist via create_all() + legacy DBMigration system. +Current tables already exist via SQLAlchemy metadata create_all(). Revision ID: 0001_baseline Revises: None @@ -15,8 +15,7 @@ depends_on = None def upgrade() -> None: - # No-op: existing schema is already at database_version=25 - # This revision serves as the Alembic baseline. + # No-op: this revision serves as the Alembic baseline. pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py index d3b93263a..61a2669e8 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py +++ b/src/langbot/pkg/persistence/alembic/versions/0006_normalize_mcp_remote_mode.py @@ -10,7 +10,7 @@ ssereadtimeout) live in ``extra_args`` and are left untouched — the auto-detecting remote transport consumes them regardless. Revision ID: 0006_normalize_mcp_remote_mode -Revises: 8d3a1f2c4b6e +Revises: 0005_add_llm_context_length Create Date: 2026-06-21 """ @@ -18,7 +18,7 @@ import sqlalchemy as sa from alembic import op revision = '0006_normalize_mcp_remote_mode' -down_revision = '8d3a1f2c4b6e' +down_revision = '0005_add_llm_context_length' branch_labels = None depends_on = None diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py index a13f2caa6..df461ce4a 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -5,6 +5,8 @@ Revises: 0006_normalize_mcp_remote_mode Create Date: 2026-06-26 """ +import json + import sqlalchemy as sa from alembic import op @@ -14,19 +16,64 @@ branch_labels = None depends_on = None +_BOT_ADMINS = sa.table( + 'bot_admins', + sa.column('bot_uuid', sa.String(255)), + sa.column('launcher_type', sa.String(64)), + sa.column('launcher_id', sa.String(255)), +) + + +def _upsert_admin(conn: sa.Connection, *, bot_uuid: str, launcher_type: str, launcher_id: str) -> None: + values = { + 'bot_uuid': bot_uuid, + 'launcher_type': launcher_type, + 'launcher_id': launcher_id, + } + conflict_columns = [ + _BOT_ADMINS.c.bot_uuid, + _BOT_ADMINS.c.launcher_type, + _BOT_ADMINS.c.launcher_id, + ] + + if conn.dialect.name == 'postgresql': + from sqlalchemy.dialects.postgresql import insert + + statement = insert(_BOT_ADMINS).values(**values).on_conflict_do_nothing(index_elements=conflict_columns) + elif conn.dialect.name == 'sqlite': + from sqlalchemy.dialects.sqlite import insert + + statement = insert(_BOT_ADMINS).values(**values).on_conflict_do_nothing(index_elements=conflict_columns) + else: + existing = conn.execute( + sa.select(sa.literal(1)) + .select_from(_BOT_ADMINS) + .where( + _BOT_ADMINS.c.bot_uuid == bot_uuid, + _BOT_ADMINS.c.launcher_type == launcher_type, + _BOT_ADMINS.c.launcher_id == launcher_id, + ) + .limit(1) + ).first() + if existing is not None: + return + statement = sa.insert(_BOT_ADMINS).values(**values) + + conn.execute(statement) + + def upgrade() -> None: conn = op.get_bind() - if 'bot_admins' in sa.inspect(conn).get_table_names(): - return - op.create_table( - 'bot_admins', - sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), - sa.Column('bot_uuid', sa.String(255), nullable=False), - sa.Column('launcher_type', sa.String(64), nullable=False), - sa.Column('launcher_id', sa.String(255), nullable=False), - sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), - sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), - ) + if 'bot_admins' not in sa.inspect(conn).get_table_names(): + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) # Migrate old config-based admins into the first bot (best-effort) inspector = sa.inspect(conn) @@ -48,28 +95,27 @@ def upgrade() -> None: if meta_row is None: return - import json - try: cfg = json.loads(meta_row[0]) - except Exception: + except (TypeError, json.JSONDecodeError): return admins = cfg.get('admins', []) + if not isinstance(admins, list): + return for entry in admins: + if not isinstance(entry, str): + continue parts = entry.split('_', 1) if len(parts) != 2: continue launcher_type, launcher_id = parts - try: - conn.execute( - sa.text( - 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)' - ), - {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, - ) - except Exception: - pass + _upsert_admin( + conn, + bot_uuid=first_bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) # Remove admins key from stored config if 'admins' in cfg: @@ -81,4 +127,5 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_table('bot_admins') + if 'bot_admins' in sa.inspect(op.get_bind()).get_table_names(): + op.drop_table('bot_admins') diff --git a/src/langbot/pkg/persistence/alembic/versions/0009_migrate_routing_to_event_bindings.py b/src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py similarity index 81% rename from src/langbot/pkg/persistence/alembic/versions/0009_migrate_routing_to_event_bindings.py rename to src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py index 5a234ca3f..99208d2f3 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0009_migrate_routing_to_event_bindings.py +++ b/src/langbot/pkg/persistence/alembic/versions/0009_migrate_event_bindings.py @@ -3,7 +3,7 @@ Bindings keep referencing the original Pipeline UUIDs. This migration never creates Agent rows or copies Pipeline runner configuration. -Revision ID: 0009_migrate_routing_to_event_bindings +Revision ID: 0009_migrate_event_bindings Revises: 0008_agent_product_surface Create Date: 2026-06-26 """ @@ -14,7 +14,7 @@ import uuid import sqlalchemy as sa from alembic import op -revision = '0009_migrate_routing_to_event_bindings' +revision = '0009_migrate_event_bindings' down_revision = '0008_agent_product_surface' depends_on = None @@ -30,24 +30,28 @@ def _column_exists(table_name: str, column_name: str) -> bool: def _rule_to_filters(rule: dict) -> list[dict] | None: - """Convert a pipeline_routing_rule to event_binding filters (best effort). - - Rules that don't map cleanly (message_content, message_has_element) are - skipped — callers should handle None as "cannot migrate". - """ + """Convert one legacy Pipeline routing rule to an EBA event filter.""" rule_type = rule.get('type') operator = rule.get('operator', 'eq') value = rule.get('value', '') if rule_type == 'launcher_type': - if value == 'group': - return [{'field': 'group', 'operator': 'neq', 'value': None}] - if value == 'person': - return [{'field': 'group', 'operator': 'eq', 'value': None}] - elif rule_type == 'launcher_id': + return [{'field': 'chat_type', 'operator': operator, 'value': value}] + if rule_type == 'launcher_id': return [{'field': 'chat_id', 'operator': operator, 'value': value}] + if rule_type == 'message_content': + return [{'field': 'message_text', 'operator': operator, 'value': value}] + if rule_type == 'message_has_element': + element_operator = { + 'eq': 'contains', + 'neq': 'not_contains', + }.get(operator) + if element_operator is None: + # The legacy matcher treated every other operator as non-matching. + element_operator = 'unsupported_legacy_operator' + return [{'field': 'message_element_types', 'operator': element_operator, 'value': value}] - return None # message_content / message_has_element: no clean mapping + return None def upgrade() -> None: diff --git a/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py b/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py new file mode 100644 index 000000000..ade946f9c --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_agent_heads.py @@ -0,0 +1,19 @@ +"""merge mcp resource and agent product migration heads + +Revision ID: 0010_merge_mcp_agent_heads +Revises: 0008_mcp_resource_prefs, 0009_migrate_event_bindings +Create Date: 2026-06-30 +""" + +revision = '0010_merge_mcp_agent_heads' +down_revision = ('0008_mcp_resource_prefs', '0009_migrate_event_bindings') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_resource_agent_heads.py b/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_resource_agent_heads.py deleted file mode 100644 index 181f96122..000000000 --- a/src/langbot/pkg/persistence/alembic/versions/0010_merge_mcp_resource_agent_heads.py +++ /dev/null @@ -1,19 +0,0 @@ -"""merge mcp resource and agent product migration heads - -Revision ID: 0010_merge_mcp_resource_agent_heads -Revises: 0008_mcp_resource_prefs, 0009_migrate_routing_to_event_bindings -Create Date: 2026-06-30 -""" - -revision = '0010_merge_mcp_resource_agent_heads' -down_revision = ('0008_mcp_resource_prefs', '0009_migrate_routing_to_event_bindings') -branch_labels = None -depends_on = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py b/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py index 6fc9e0067..b41fdae30 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py +++ b/src/langbot/pkg/persistence/alembic/versions/0011_drop_legacy_bot_routing.py @@ -1,7 +1,7 @@ """drop legacy bot pipeline routing columns Revision ID: 0011_drop_legacy_bot_routing -Revises: 0010_merge_mcp_resource_agent_heads +Revises: 0010_merge_mcp_agent_heads Create Date: 2026-07-01 """ @@ -10,7 +10,7 @@ import sqlalchemy as sa revision = '0011_drop_legacy_bot_routing' -down_revision = '0010_merge_mcp_resource_agent_heads' +down_revision = '0010_merge_mcp_agent_heads' branch_labels = None depends_on = None diff --git a/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py b/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py new file mode 100644 index 000000000..60916bf90 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0012_add_monitoring_tool_calls.py @@ -0,0 +1,67 @@ +"""add monitoring tool calls + +Revision ID: 0012_monitoring_tool_calls +Revises: 0011_drop_legacy_bot_routing +Create Date: 2026-07-12 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = '0012_monitoring_tool_calls' +down_revision = '0011_drop_legacy_bot_routing' +branch_labels = None +depends_on = None + + +_INDEXES = { + 'ix_monitoring_tool_calls_timestamp': ['timestamp'], + 'ix_monitoring_tool_calls_bot_id': ['bot_id'], + 'ix_monitoring_tool_calls_pipeline_id': ['pipeline_id'], + 'ix_monitoring_tool_calls_session_id': ['session_id'], + 'ix_monitoring_tool_calls_message_id': ['message_id'], +} + + +def _table_exists() -> bool: + return 'monitoring_tool_calls' in sa.inspect(op.get_bind()).get_table_names() + + +def _index_names() -> set[str]: + if not _table_exists(): + return set() + return {index['name'] for index in sa.inspect(op.get_bind()).get_indexes('monitoring_tool_calls')} + + +def upgrade() -> None: + if not _table_exists(): + op.create_table( + 'monitoring_tool_calls', + sa.Column('id', sa.String(255), primary_key=True), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('tool_name', sa.String(255), nullable=False), + sa.Column('tool_source', sa.String(50), nullable=False), + sa.Column('duration', sa.Integer(), nullable=False), + sa.Column('status', sa.String(50), nullable=False), + sa.Column('bot_id', sa.String(255), nullable=False), + sa.Column('bot_name', sa.String(255), nullable=False), + sa.Column('pipeline_id', sa.String(255), nullable=False), + sa.Column('pipeline_name', sa.String(255), nullable=False), + sa.Column('session_id', sa.String(255), nullable=True), + sa.Column('message_id', sa.String(255), nullable=True), + sa.Column('arguments', sa.Text(), nullable=True), + sa.Column('result', sa.Text(), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + ) + + existing_indexes = _index_names() + for index_name, columns in _INDEXES.items(): + if index_name not in existing_indexes: + op.create_index(index_name, 'monitoring_tool_calls', columns, unique=False) + + +def downgrade() -> None: + if _table_exists(): + op.drop_table('monitoring_tool_calls') diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 7ad7b9683..32144e3fb 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -7,15 +7,14 @@ import typing import sqlalchemy.ext.asyncio as sqlalchemy_asyncio import sqlalchemy -from . import database, migration -from ..entity.persistence import base, metadata, model as persistence_model +from . import database +from ..entity.persistence import base, model as persistence_model from ..entity import persistence from ..core import app -from ..utils import constants, importutil -from . import databases, migrations +from ..utils import importutil +from . import databases importutil.import_modules_in_pkg(databases) -importutil.import_modules_in_pkg(migrations) importutil.import_modules_in_pkg(persistence) @@ -43,40 +42,6 @@ class PersistenceManager: break await self.create_tables() - - # run migrations - database_version = await self.execute_async( - sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == 'database_version') - ) - - database_version = int(database_version.fetchone()[1]) - required_database_version = constants.required_database_version - - if database_version < required_database_version: - migrations = migration.preregistered_db_migrations - migrations.sort(key=lambda x: x.number) - - last_migration_number = database_version - - for migration_cls in migrations: - migration_instance = migration_cls(self.ap) - - if ( - migration_instance.number > database_version - and migration_instance.number <= required_database_version - ): - await migration_instance.upgrade() - await self.execute_async( - sqlalchemy.update(metadata.Metadata) - .where(metadata.Metadata.key == 'database_version') - .values({'value': str(migration_instance.number)}) - ) - last_migration_number = migration_instance.number - self.ap.logger.info(f'Migration {migration_instance.number} completed.') - - self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.') - - # Run Alembic migrations (new migration system) await self._run_alembic_migrations() await self.write_space_model_providers() @@ -88,19 +53,6 @@ class PersistenceManager: await conn.commit() - # ======= write initial data ======= - - # write initial metadata - self.ap.logger.info('Creating initial metadata...') - for item in metadata.initial_metadata: - # check if the item exists - result = await self.execute_async( - sqlalchemy.select(metadata.Metadata).where(metadata.Metadata.key == item['key']) - ) - row = result.first() - if row is None: - await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item)) - async def write_space_model_providers(self): space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get( 'models_gateway_api_url', 'https://api.langbot.cloud/v1' @@ -139,7 +91,7 @@ class PersistenceManager: # ================================= async def _run_alembic_migrations(self): - """Run Alembic-based migrations after legacy migrations complete.""" + """Run Alembic migrations for supported 4.x databases.""" from . import alembic_runner engine = self.get_db_engine() diff --git a/src/langbot/pkg/persistence/migration.py b/src/langbot/pkg/persistence/migration.py deleted file mode 100644 index 294e30ca2..000000000 --- a/src/langbot/pkg/persistence/migration.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import typing -import abc - -from ..core import app - - -preregistered_db_migrations: list[typing.Type[DBMigration]] = [] - - -def migration_class(number: int): - """Migration class decorator""" - - def wrapper(cls: typing.Type[DBMigration]) -> typing.Type[DBMigration]: - cls.number = number - preregistered_db_migrations.append(cls) - return cls - - return wrapper - - -class DBMigration(abc.ABC): - """Database migration""" - - number: int - """Migration number""" - - def __init__(self, ap: app.Application): - self.ap = ap - - @abc.abstractmethod - async def upgrade(self): - """Upgrade""" - pass - - @abc.abstractmethod - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/README.md b/src/langbot/pkg/persistence/migrations/README.md deleted file mode 100644 index 8f62f1d26..000000000 --- a/src/langbot/pkg/persistence/migrations/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Legacy migrations (DEPRECATED — do not add new files here) - -This directory holds the **legacy 3.x database migration system** -(`DBMigration` subclasses in `dbmXXX_*.py`, registered via -`@migration.migration_class(N)` and run from `pkg/persistence/mgr.py`). - -**This system is frozen. Do not add new `dbmXXX_*.py` migrations.** - -The chain is capped at version 25 (`required_database_version = 25` in -`pkg/utils/constants.py`). These files exist only to upgrade pre-existing -3.x databases up to the Alembic baseline (`0001_baseline`). Removing them -would break in-place upgrades from old installations, so they are kept -read-only. - -## All new schema changes use Alembic - -Migrations now live in `pkg/persistence/alembic/versions/`. To create one: - -```bash -uv run python -m langbot.pkg.persistence.alembic_runner autogenerate "description of your change" -``` - -(requires `data/config.yaml` to exist). Review and edit the generated -script before committing — Alembic migrations run automatically on startup -and must be idempotent and guard against missing tables (the test suite -runs them against empty databases). - -### Rules for Alembic revision ids - -- Keep the revision id **≤ 32 characters** — PostgreSQL stores - `alembic_version.version_num` as `varchar(32)` and will raise - `StringDataRightTruncationError` on overflow. -- Guard every `op` call against a missing table / missing column - (`inspector.get_table_names()` / `inspector.get_columns()`); fresh - installs create the schema via `create_all()` and stamp the baseline, - so migrations may run against tables that already match or do not exist. diff --git a/src/langbot/pkg/persistence/migrations/__init__.py b/src/langbot/pkg/persistence/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py b/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py deleted file mode 100644 index d51cb9e34..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm001_migrate_v3_config.py +++ /dev/null @@ -1,218 +0,0 @@ -from .. import migration -from copy import deepcopy -import uuid -import os -import sqlalchemy -import shutil - -from ...config import manager as config_manager -from ...entity.persistence import ( - model as persistence_model, - pipeline as persistence_pipeline, - bot as persistence_bot, -) - - -@migration.migration_class(1) -class DBMigrateV3Config(migration.DBMigration): - """Migrate v3 config to v4 database""" - - async def upgrade(self): - """Upgrade""" - """ - Migrate all config files under data/config. - After migration, all previous config files are saved under data/legacy/config. - After migration, all config files under data/metadata/ are saved under data/legacy/metadata. - """ - - if self.ap.provider_cfg is None: - return - - # ======= Migrate model ======= - # Only migrate the currently selected model - model_name = self.ap.provider_cfg.data.get('model', 'gpt-4o') - - model_requester = 'openai-chat-completions' - model_requester_config = {} - model_api_keys = ['sk-proj-1234567890'] - model_abilities = [] - model_extra_args = {} - - if os.path.exists('data/metadata/llm-models.json'): - _llm_model_meta = await config_manager.load_json_config('data/metadata/llm-models.json', completion=False) - - for item in _llm_model_meta.data.get('list', []): - if item.get('name') == model_name: - if 'model_name' in item: - model_name = item['model_name'] - if 'requester' in item: - model_requester = item['requester'] - if 'token_mgr' in item: - _token_mgr = item['token_mgr'] - - if _token_mgr in self.ap.provider_cfg.data.get('keys', {}): - model_api_keys = self.ap.provider_cfg.data.get('keys', {})[_token_mgr] - - if 'tool_call_supported' in item and item['tool_call_supported']: - model_abilities.append('func_call') - - if 'vision_supported' in item and item['vision_supported']: - model_abilities.append('vision') - - if ( - model_requester in self.ap.provider_cfg.data.get('requester', {}) - and 'args' in self.ap.provider_cfg.data.get('requester', {})[model_requester] - ): - model_extra_args = self.ap.provider_cfg.data.get('requester', {})[model_requester]['args'] - - if model_requester in self.ap.provider_cfg.data.get('requester', {}): - model_requester_config = self.ap.provider_cfg.data.get('requester', {})[model_requester] - model_requester_config = { - 'base_url': model_requester_config['base-url'], - 'timeout': model_requester_config['timeout'], - } - - break - - model_uuid = str(uuid.uuid4()) - - llm_model_data = { - 'uuid': model_uuid, - 'name': model_name, - 'description': '由 LangBot v3 迁移而来', - 'requester': model_requester, - 'requester_config': model_requester_config, - 'api_keys': model_api_keys, - 'abilities': model_abilities, - 'extra_args': model_extra_args, - } - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_model.LLMModel).values(**llm_model_data) - ) - - # ======= Migrate pipeline config ======= - # Modify to default pipeline - default_pipeline = [ - self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline) - for pipeline in ( - await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.is_default == True - ) - ) - ).all() - ][0] - - pipeline_uuid = str(uuid.uuid4()) - pipeline_name = 'ChatPipeline' - - if default_pipeline: - pipeline_name = default_pipeline['name'] - pipeline_uuid = default_pipeline['uuid'] - - pipeline_config = default_pipeline['config'] - - # trigger - pipeline_config['trigger']['group-respond-rules'] = self.ap.pipeline_cfg.data['respond-rules']['default'] - pipeline_config['trigger']['access-control'] = self.ap.pipeline_cfg.data['access-control'] - pipeline_config['trigger']['ignore-rules'] = self.ap.pipeline_cfg.data['ignore-rules'] - - # safety - pipeline_config['safety']['content-filter'] = { - 'scope': 'all', - 'check-sensitive-words': self.ap.pipeline_cfg.data['check-sensitive-words'], - } - pipeline_config['safety']['rate-limit'] = { - 'window-length': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['window-size'], - 'limitation': self.ap.pipeline_cfg.data['rate-limit']['fixwin']['default']['limit'], - 'strategy': self.ap.pipeline_cfg.data['rate-limit']['strategy'], - } - - # output - pipeline_config['output']['long-text-processing'] = self.ap.platform_cfg.data['long-text-process'] - pipeline_config['output']['force-delay'] = self.ap.platform_cfg.data['force-delay'] - pipeline_config['output']['misc'] = { - 'hide-exception': self.ap.platform_cfg.data['hide-exception-info'], - 'quote-origin': self.ap.platform_cfg.data['quote-origin'], - 'at-sender': self.ap.platform_cfg.data['at-sender'], - 'track-function-calls': self.ap.platform_cfg.data['track-function-calls'], - } - - default_pipeline['description'] = default_pipeline['description'] + ' [已迁移 LangBot v3 配置]' - default_pipeline['config'] = pipeline_config - default_pipeline.pop('created_at') - default_pipeline.pop('updated_at') - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.update(persistence_pipeline.LegacyPipeline) - .values(default_pipeline) - .where(persistence_pipeline.LegacyPipeline.uuid == default_pipeline['uuid']) - ) - - # ======= Migrate bot ======= - # Only migrate enabled bots - for adapter in self.ap.platform_cfg.data.get('platform-adapters', []): - if not adapter.get('enable'): - continue - - args = deepcopy(adapter) - args.pop('adapter') - args.pop('enable') - - bot_data = { - 'uuid': str(uuid.uuid4()), - 'name': adapter.get('adapter'), - 'description': '由 LangBot v3 迁移而来', - 'adapter': adapter.get('adapter'), - 'adapter_config': args, - 'enable': True, - 'use_pipeline_uuid': pipeline_uuid, - 'use_pipeline_name': pipeline_name, - } - - await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(**bot_data)) - - # ======= Migrate system settings ======= - self.ap.instance_config.data['admins'] = self.ap.system_cfg.data['admin-sessions'] - self.ap.instance_config.data['api']['port'] = self.ap.system_cfg.data['http-api']['port'] - self.ap.instance_config.data['command'] = { - 'prefix': self.ap.command_cfg.data['command-prefix'], - 'enable': self.ap.command_cfg.data['command-enable'] - if 'command-enable' in self.ap.command_cfg.data - else True, - 'privilege': self.ap.command_cfg.data['privilege'], - } - self.ap.instance_config.data['concurrency']['pipeline'] = self.ap.system_cfg.data['pipeline-concurrency'] - self.ap.instance_config.data['concurrency']['session'] = self.ap.system_cfg.data['session-concurrency'][ - 'default' - ] - self.ap.instance_config.data['mcp'] = self.ap.provider_cfg.data['mcp'] - self.ap.instance_config.data['proxy'] = self.ap.system_cfg.data['network-proxies'] - await self.ap.instance_config.dump_config() - - # ======= move files ======= - # Migrate all config files under data/config - all_legacy_dir_name = [ - 'config', - # 'metadata', - 'prompts', - 'scenario', - ] - - def move_legacy_files(dir_name: str): - if not os.path.exists(f'data/legacy/{dir_name}'): - os.makedirs(f'data/legacy/{dir_name}') - - if os.path.exists(f'data/{dir_name}'): - for file in os.listdir(f'data/{dir_name}'): - if file.endswith('.json'): - shutil.move(f'data/{dir_name}/{file}', f'data/legacy/{dir_name}/{file}') - - os.rmdir(f'data/{dir_name}') - - for dir_name in all_legacy_dir_name: - move_legacy_files(dir_name) - - async def downgrade(self): - """Downgrade""" diff --git a/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py b/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py deleted file mode 100644 index 471e28cdc..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm002_combine_quote_msg_config.py +++ /dev/null @@ -1,55 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(2) -class DBMigrateCombineQuoteMsgConfig(migration.DBMigration): - """Combine quote message config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'trigger' exists - if 'trigger' not in config: - config['trigger'] = {} - - # Ensure 'misc' exists in 'trigger' - if 'misc' not in config['trigger']: - config['trigger']['misc'] = {} - - # Add 'combine-quote-message' if not exists - if 'combine-quote-message' not in config['trigger']['misc']: - config['trigger']['misc']['combine-quote-message'] = False - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py b/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py deleted file mode 100644 index 602562cf8..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm003_n8n_config.py +++ /dev/null @@ -1,62 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(3) -class DBMigrateN8nConfig(migration.DBMigration): - """N8n config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'ai' exists - if 'ai' not in config: - config['ai'] = {} - - # Add 'n8n-service-api' if not exists - if 'n8n-service-api' not in config['ai']: - config['ai']['n8n-service-api'] = { - 'webhook-url': 'http://your-n8n-webhook-url', - 'auth-type': 'none', - 'basic-username': '', - 'basic-password': '', - 'jwt-secret': '', - 'jwt-algorithm': 'HS256', - 'header-name': '', - 'header-value': '', - 'timeout': 120, - 'output-key': 'response', - } - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py b/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py deleted file mode 100644 index d422102cd..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm004_rag_kb_uuid.py +++ /dev/null @@ -1,53 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(4) -class DBMigrateRAGKBUUID(migration.DBMigration): - """RAG知识库UUID""" - - async def upgrade(self): - """升级""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'ai' not in config: - config['ai'] = {} - if 'local-agent' not in config['ai']: - config['ai']['local-agent'] = {} - - # Add 'knowledge-base' if not exists - if 'knowledge-base' not in config['ai']['local-agent']: - config['ai']['local-agent']['knowledge-base'] = '' - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py b/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py deleted file mode 100644 index 7e71bb0e9..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm005_pipeline_remove_cot_config.py +++ /dev/null @@ -1,53 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(5) -class DBMigratePipelineRemoveCotConfig(migration.DBMigration): - """Pipeline remove cot config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'output' not in config: - config['output'] = {} - if 'misc' not in config['output']: - config['output']['misc'] = {} - - # Add 'remove-think' if not exists - if 'remove-think' not in config['output']['misc']: - config['output']['misc']['remove-think'] = False - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py b/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py deleted file mode 100644 index d25e987aa..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm006_langflow_api_config.py +++ /dev/null @@ -1,58 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(6) -class DBMigrateLangflowApiConfig(migration.DBMigration): - """Langflow API config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure 'ai' exists - if 'ai' not in config: - config['ai'] = {} - - # Add 'langflow-api' if not exists - if 'langflow-api' not in config['ai']: - config['ai']['langflow-api'] = { - 'base-url': 'http://localhost:7860', - 'api-key': 'your-api-key', - 'flow-id': 'your-flow-id', - 'input-type': 'chat', - 'output-type': 'chat', - 'tweaks': '{}', - } - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py b/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py deleted file mode 100644 index 26c38c577..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm007_plugin_install_source.py +++ /dev/null @@ -1,44 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(7) -class DBMigratePluginInstallSource(migration.DBMigration): - """插件安装来源""" - - async def upgrade(self): - """升级""" - # 查询表结构获取所有列名(异步执行 SQL) - - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_settings';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(plugin_settings);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # 检查并添加 install_source 列 - if 'install_source' not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "ALTER TABLE plugin_settings ADD COLUMN install_source VARCHAR(255) NOT NULL DEFAULT 'github'" - ) - ) - - # 检查并添加 install_info 列 - if 'install_info' not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE plugin_settings ADD COLUMN install_info JSON NOT NULL DEFAULT '{}'") - ) - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py b/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py deleted file mode 100644 index 23da3568f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm008_plugin_config.py +++ /dev/null @@ -1,22 +0,0 @@ -from .. import migration - - -@migration.migration_class(8) -class DBMigratePluginConfig(migration.DBMigration): - """插件配置""" - - async def upgrade(self): - """升级""" - - if 'plugin' not in self.ap.instance_config.data: - self.ap.instance_config.data['plugin'] = { - 'runtime_ws_url': 'ws://langbot_plugin_runtime:5400/control/ws', - 'enable_marketplace': True, - 'cloud_service_url': 'https://space.langbot.app', - } - - await self.ap.instance_config.dump_config() - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py b/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py deleted file mode 100644 index 927da4bf0..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm009_pipeline_extension_preferences.py +++ /dev/null @@ -1,20 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(9) -class DBMigratePipelineExtensionPreferences(migration.DBMigration): - """Pipeline extension preferences""" - - async def upgrade(self): - """Upgrade""" - - sql_text = sqlalchemy.text( - "ALTER TABLE legacy_pipelines ADD COLUMN extensions_preferences JSON NOT NULL DEFAULT '{}'" - ) - await self.ap.persistence_mgr.execute_async(sql_text) - - async def downgrade(self): - """Downgrade""" - sql_text = sqlalchemy.text('ALTER TABLE legacy_pipelines DROP COLUMN extensions_preferences') - await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py b/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py deleted file mode 100644 index fcbe149df..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm010_pipeline_multi_knowledge_base.py +++ /dev/null @@ -1,105 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(10) -class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration): - """Pipeline support multiple knowledge base binding""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Convert knowledge-base from string to array - if 'ai' in config and 'local-agent' in config['ai']: - current_kb = config['ai']['local-agent'].get('knowledge-base', '') - - # If it's already a list, skip - if isinstance(current_kb, list): - continue - - # Convert string to list - if current_kb and current_kb != '__none__': - config['ai']['local-agent']['knowledge-bases'] = [current_kb] - else: - config['ai']['local-agent']['knowledge-bases'] = [] - - # Remove old field - if 'knowledge-base' in config['ai']['local-agent']: - del config['ai']['local-agent']['knowledge-base'] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Convert knowledge-bases from array back to string - if 'ai' in config and 'local-agent' in config['ai']: - current_kbs = config['ai']['local-agent'].get('knowledge-bases', []) - - # If it's already a string, skip - if isinstance(current_kbs, str): - continue - - # Convert list to string (take first one or empty) - if current_kbs and len(current_kbs) > 0: - config['ai']['local-agent']['knowledge-base'] = current_kbs[0] - else: - config['ai']['local-agent']['knowledge-base'] = '' - - # Remove new field - if 'knowledge-bases' in config['ai']['local-agent']: - del config['ai']['local-agent']['knowledge-bases'] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) diff --git a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py b/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py deleted file mode 100644 index 98070d4f2..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm011_dify_base_prompt_config.py +++ /dev/null @@ -1,55 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(11) -class DBMigrateDifyApiConfig(migration.DBMigration): - """Dify base prompt config""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - # Ensure nested structure exists - if 'ai' not in config: - config['ai'] = {} - if 'dify-service-api' not in config['ai']: - config['ai']['dify-service-api'] = {} - - # Add 'base-prompt' if not exists - if 'base-prompt' not in config['ai']['dify-service-api']: - config['ai']['dify-service-api']['base-prompt'] = ( - 'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.', - ) - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py b/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py deleted file mode 100644 index 6b1aca2d0..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm012_pipeline_extensions_enable_all.py +++ /dev/null @@ -1,73 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(12) -class DBMigratePipelineExtensionsEnableAll(migration.DBMigration): - """Pipeline extensions enable all""" - - async def upgrade(self): - """Upgrade""" - # Read all pipelines using raw SQL - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - extensions_preferences = ( - json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - ) - - # Ensure extensions_preferences is a dict - if extensions_preferences is None: - extensions_preferences = {} - - # Add 'enable_all_plugins' if not exists - if 'enable_all_plugins' not in extensions_preferences: - if 'plugins' in extensions_preferences: - extensions_preferences['enable_all_plugins'] = False - else: - extensions_preferences['enable_all_plugins'] = True - extensions_preferences['plugins'] = [] - - # Add 'enable_all_mcp_servers' if not exists - if 'enable_all_mcp_servers' not in extensions_preferences: - if 'mcp_servers' in extensions_preferences: - extensions_preferences['enable_all_mcp_servers'] = False - else: - extensions_preferences['enable_all_mcp_servers'] = True - extensions_preferences['mcp_servers'] = [] - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - { - 'extensions_preferences': json.dumps(extensions_preferences), - 'for_version': current_version, - 'uuid': uuid, - }, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid' - ), - { - 'extensions_preferences': json.dumps(extensions_preferences), - 'for_version': current_version, - 'uuid': uuid, - }, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py b/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py deleted file mode 100644 index 19f37e834..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm013_knowledge_base_updated_at.py +++ /dev/null @@ -1,49 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(13) -class DBMigrateKnowledgeBaseUpdatedAt(migration.DBMigration): - """Add updated_at field to knowledge_bases table""" - - async def upgrade(self): - """Upgrade""" - # Get all column names from the table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - "SELECT column_name FROM information_schema.columns WHERE table_name = 'knowledge_bases';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(knowledge_bases);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Check and add updated_at column - if 'updated_at' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'ALTER TABLE knowledge_bases ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP' - ) - ) - else: - # SQLite doesn't support DEFAULT CURRENT_TIMESTAMP in ALTER TABLE - # Add column without default first - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases ADD COLUMN updated_at DATETIME') - ) - - # Set initial updated_at values to created_at for existing records - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE knowledge_bases SET updated_at = created_at WHERE updated_at IS NULL') - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py b/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py deleted file mode 100644 index ea93e9304..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm014_space_account_support.py +++ /dev/null @@ -1,94 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(14) -class DBMigrateSpaceAccountSupport(migration.DBMigration): - """Add Space account support fields to users table""" - - async def upgrade(self): - """Upgrade""" - # Get all column names from the users table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT column_name FROM information_schema.columns WHERE table_name = 'users';") - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('PRAGMA table_info(users);')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Add account_type column - if 'account_type' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("ALTER TABLE users ADD COLUMN account_type VARCHAR(32) DEFAULT 'local' NOT NULL") - ) - - # Add space_account_uuid column - if 'space_account_uuid' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_account_uuid VARCHAR(255)') - ) - - # Add space_access_token column - if 'space_access_token' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token TEXT') - ) - - # Add space_refresh_token column - if 'space_refresh_token' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_refresh_token TEXT') - ) - - # Add space_access_token_expires_at column - if 'space_access_token_expires_at' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at TIMESTAMP') - ) - - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_access_token_expires_at DATETIME') - ) - - # Add space_api_key column - if 'space_api_key' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE users ADD COLUMN space_api_key VARCHAR(255)') - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py b/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py deleted file mode 100644 index 363ff6663..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm015_model_source_tracking.py +++ /dev/null @@ -1,15 +0,0 @@ -from .. import migration - - -# this is a deprecated migration -@migration.migration_class(15) -class DBMigrateModelSourceTracking(migration.DBMigration): - """Add source tracking fields to models tables for Space integration""" - - async def upgrade(self): - """Upgrade""" - pass - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py b/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py deleted file mode 100644 index 150e5209f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm016_model_provider_refactor.py +++ /dev/null @@ -1,305 +0,0 @@ -import uuid as uuid_lib - -import sqlalchemy -from .. import migration - - -@migration.migration_class(16) -class DBMigrateModelProviderRefactor(migration.DBMigration): - """Refactor model structure: create providers from existing models and update references""" - - async def upgrade(self): - """Upgrade""" - # Step 1: Create model_providers table if not exists - await self._create_providers_table() - - # Step 2: Migrate existing models to use providers - await self._migrate_llm_models() - await self._migrate_embedding_models() - - # Step 3: Remove deprecated columns - await self._cleanup_columns() - - async def _create_providers_table(self): - """Create model_providers table""" - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - CREATE TABLE IF NOT EXISTS model_providers ( - uuid VARCHAR(255) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - requester VARCHAR(255) NOT NULL, - base_url VARCHAR(512) NOT NULL, - api_keys JSONB NOT NULL DEFAULT '[]', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - """) - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - CREATE TABLE IF NOT EXISTS model_providers ( - uuid VARCHAR(255) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - requester VARCHAR(255) NOT NULL, - base_url VARCHAR(512) NOT NULL, - api_keys JSON NOT NULL DEFAULT '[]', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - """) - ) - - async def _migrate_llm_models(self): - """Migrate LLM models to use providers""" - llm_columns = await self._get_columns('llm_models') - - # Add provider_uuid column if not exists - if 'provider_uuid' not in llm_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN provider_uuid VARCHAR(255)') - ) - - # Add prefered_ranking column if not exists - if 'prefered_ranking' not in llm_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE llm_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') - ) - - # Only migrate if old columns exist - if 'requester' not in llm_columns: - return - - # Get all LLM models with old structure - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM llm_models') - ) - models = result.fetchall() - - # Create providers and update models - provider_cache = {} # (requester, base_url, api_keys_str) -> provider_uuid - - for model in models: - model_uuid, model_name, requester, requester_config, api_keys = model - - # Extract base_url from requester_config - base_url = '' - if requester_config: - if isinstance(requester_config, str): - import json - - requester_config = json.loads(requester_config) - base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') - - # Parse api_keys if it's a string - if isinstance(api_keys, str): - import json - - try: - api_keys = json.loads(api_keys) - except Exception: - api_keys = [] - if not api_keys: - api_keys = [] - - # Create cache key - api_keys_str = str(sorted(api_keys)) if api_keys else '[]' - cache_key = (requester, base_url, api_keys_str) - - if cache_key in provider_cache: - provider_uuid = provider_cache[cache_key] - else: - # Create new provider - provider_uuid = str(uuid_lib.uuid4()) - provider_name = f'{requester}' - if base_url: - # Extract domain for name - try: - from urllib.parse import urlparse - - parsed = urlparse(base_url) - provider_name = parsed.netloc or requester - except Exception: - pass - - import json - - api_keys_json = json.dumps(api_keys) if api_keys else '[]' - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) - VALUES (:uuid, :name, :requester, :base_url, :api_keys) - """), - { - 'uuid': provider_uuid, - 'name': provider_name, - 'requester': requester, - 'base_url': base_url, - 'api_keys': api_keys_json, - }, - ) - provider_cache[cache_key] = provider_uuid - - # Update model with provider_uuid - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE llm_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), - {'provider_uuid': provider_uuid, 'uuid': model_uuid}, - ) - - async def _migrate_embedding_models(self): - """Migrate embedding models to use providers""" - embedding_columns = await self._get_columns('embedding_models') - - # Add provider_uuid column if not exists - if 'provider_uuid' not in embedding_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN provider_uuid VARCHAR(255)') - ) - - # Add prefered_ranking column if not exists - if 'prefered_ranking' not in embedding_columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE embedding_models ADD COLUMN prefered_ranking INTEGER NOT NULL DEFAULT 0') - ) - - # Only migrate if old columns exist - if 'requester' not in embedding_columns: - return - - # Get all embedding models with old structure - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, name, requester, requester_config, api_keys FROM embedding_models') - ) - models = result.fetchall() - - # Get existing providers - provider_result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, requester, base_url, api_keys FROM model_providers') - ) - existing_providers = provider_result.fetchall() - - provider_cache = {} - for p in existing_providers: - p_uuid, p_requester, p_base_url, p_api_keys = p - api_keys_str = str(sorted(p_api_keys)) if p_api_keys else '[]' - provider_cache[(p_requester, p_base_url, api_keys_str)] = p_uuid - - for model in models: - model_uuid, model_name, requester, requester_config, api_keys = model - - base_url = '' - if requester_config: - if isinstance(requester_config, str): - import json - - requester_config = json.loads(requester_config) - base_url = requester_config.get('base_url', '') or requester_config.get('base-url', '') - - # Parse api_keys if it's a string - if isinstance(api_keys, str): - import json - - try: - api_keys = json.loads(api_keys) - except Exception: - api_keys = [] - if not api_keys: - api_keys = [] - - api_keys_str = str(sorted(api_keys)) if api_keys else '[]' - cache_key = (requester, base_url, api_keys_str) - - if cache_key in provider_cache: - provider_uuid = provider_cache[cache_key] - else: - provider_uuid = str(uuid_lib.uuid4()) - provider_name = f'{requester}' - if base_url: - try: - from urllib.parse import urlparse - - parsed = urlparse(base_url) - provider_name = parsed.netloc or requester - except Exception: - pass - - import json - - api_keys_json = json.dumps(api_keys) if api_keys else '[]' - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(""" - INSERT INTO model_providers (uuid, name, requester, base_url, api_keys) - VALUES (:uuid, :name, :requester, :base_url, :api_keys) - """), - { - 'uuid': provider_uuid, - 'name': provider_name, - 'requester': requester, - 'base_url': base_url, - 'api_keys': api_keys_json, - }, - ) - provider_cache[cache_key] = provider_uuid - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE embedding_models SET provider_uuid = :provider_uuid WHERE uuid = :uuid'), - {'provider_uuid': provider_uuid, 'uuid': model_uuid}, - ) - - async def _cleanup_columns(self): - """Remove deprecated columns from model tables""" - - llm_columns = await self._get_columns('llm_models') - deprecated_llm_cols = ['requester', 'requester_config', 'api_keys', 'description', 'source', 'space_model_id'] - for col in deprecated_llm_cols: - if col in llm_columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN IF EXISTS {col}') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE llm_models DROP COLUMN {col}') - ) - - embedding_columns = await self._get_columns('embedding_models') - deprecated_embedding_cols = [ - 'requester', - 'requester_config', - 'api_keys', - 'description', - 'source', - 'space_model_id', - ] - for col in deprecated_embedding_cols: - if col in embedding_columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN IF EXISTS {col}') - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE embedding_models DROP COLUMN {col}') - ) - - async def _get_columns(self, table_name: str) -> list: - """Get column names for a table""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" - ) - ) - all_result = result.fetchall() - return [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - all_result = result.fetchall() - return [row[1] for row in all_result] - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py b/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py deleted file mode 100644 index ba6841556..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm017_move_cloud_service_url.py +++ /dev/null @@ -1,25 +0,0 @@ -from .. import migration - - -@migration.migration_class(17) -class MoveCloudServiceUrl(migration.DBMigration): - """迁移云服务 URL 配置""" - - async def upgrade(self): - """升级""" - if 'space' not in self.ap.instance_config.data: - self.ap.instance_config.data['space'] = { - 'url': 'https://space.langbot.app', - 'models_gateway_api_url': 'https://api.langbot.cloud/v1', - 'oauth_authorize_url': 'https://space.langbot.app/auth/authorize', - 'disable_models_service': False, - } - - if 'plugin' in self.ap.instance_config.data: - self.ap.instance_config.data['plugin'].pop('cloud_service_url', None) - - await self.ap.instance_config.dump_config() - - async def downgrade(self): - """降级""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py b/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py deleted file mode 100644 index f543a30b1..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm018_add_emoji_support.py +++ /dev/null @@ -1,58 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(18) -class DBMigrateAddEmojiSupport(migration.DBMigration): - """Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables""" - - async def upgrade(self): - """Upgrade""" - # Add emoji field to knowledge_bases - await self._add_emoji_to_table('knowledge_bases', '📚') - - # Add emoji field to external_knowledge_bases - await self._add_emoji_to_table('external_knowledge_bases', '🔗') - - # Add emoji field to legacy_pipelines - await self._add_emoji_to_table('legacy_pipelines', '⚙️') - - async def _add_emoji_to_table(self, table_name: str, default_emoji: str): - """Add emoji column to specified table if it doesn't exist""" - # Get all column names from the table - columns = [] - - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';" - ) - ) - all_result = result.fetchall() - columns = [row[0] for row in all_result] - else: - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - all_result = result.fetchall() - columns = [row[1] for row in all_result] - - # Check and add emoji column - if 'emoji' not in columns: - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'") - ) - else: - # SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE - # Add column without default first - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)') - ) - - # Set default emoji value for existing records - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL") - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py deleted file mode 100644 index b1372aa71..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py +++ /dev/null @@ -1,24 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(19) -class DBMigrateMonitoringMessageRole(migration.DBMigration): - """Add role column to monitoring_messages table""" - - async def upgrade(self): - """Upgrade""" - try: - sql_text = sqlalchemy.text("ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'") - await self.ap.persistence_mgr.execute_async(sql_text) - except Exception: - # Column may already exist - pass - - async def downgrade(self): - """Downgrade""" - try: - sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role') - await self.ap.persistence_mgr.execute_async(sql_text) - except Exception: - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py b/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py deleted file mode 100644 index 616cb91a6..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm020_knowledge_engine_plugin_architecture.py +++ /dev/null @@ -1,161 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(20) -class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration): - """Migrate to unified Knowledge Engine plugin architecture. - - Changes: - - Backup existing knowledge_bases data to knowledge_bases_backup - - Clear knowledge_bases table and add new plugin architecture columns - - Drop old columns (PostgreSQL only; SQLite leaves them unmapped) - - Preserve external_knowledge_bases table as-is for future migration - - Set rag_plugin_migration_needed flag in metadata if old data exists - """ - - async def upgrade(self): - """Upgrade""" - has_internal_data = await self._backup_knowledge_bases() - has_external_data = await self._check_external_knowledge_bases() - await self._clear_knowledge_bases() - await self._add_columns_to_knowledge_bases() - await self._drop_old_columns() - if has_internal_data or has_external_data: - await self._set_migration_flag() - - async def _get_table_columns(self, table_name: str) -> list[str]: - """Get column names from a table (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' - ).bindparams(table_name=table_name) - ) - return [row[0] for row in result.fetchall()] - else: - # SQLite PRAGMA does not support bind parameters; validate identifier. - if not table_name.isidentifier(): - raise ValueError(f'Invalid table name: {table_name}') - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - return [row[1] for row in result.fetchall()] - - async def _table_exists(self, table_name: str) -> bool: - """Check if a table exists.""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' - ).bindparams(table_name=table_name) - ) - return result.scalar() - else: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( - table_name=table_name - ) - ) - return result.first() is not None - - async def _backup_knowledge_bases(self) -> bool: - """Backup knowledge_bases data. Returns True if data was backed up.""" - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;')) - count = result.scalar() - if count == 0: - return False - - # Drop backup table if it already exists (from a previous failed migration) - if await self._table_exists('knowledge_bases_backup'): - await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;')) - - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;') - ) - self.ap.logger.info( - 'Backed up %d knowledge base(s) to knowledge_bases_backup table.', - count, - ) - return True - - async def _check_external_knowledge_bases(self) -> bool: - """Check if external_knowledge_bases table exists and has data. - - The table is preserved as-is (not dropped) for future migration. - """ - if not await self._table_exists('external_knowledge_bases'): - return False - - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;') - ) - count = result.scalar() - if count > 0: - self.ap.logger.info( - 'Found %d external knowledge base(s) in external_knowledge_bases table. ' - 'Table preserved for future migration.', - count, - ) - return count > 0 - - async def _clear_knowledge_bases(self): - """Clear all rows from knowledge_bases table (preserve table structure).""" - await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;')) - - async def _add_columns_to_knowledge_bases(self): - """Add new RAG plugin architecture columns to knowledge_bases table.""" - columns = await self._get_table_columns('knowledge_bases') - - new_columns = { - 'knowledge_engine_plugin_id': 'VARCHAR', - 'collection_id': 'VARCHAR', - 'creation_settings': 'TEXT', # JSON stored as TEXT for SQLite compatibility - 'retrieval_settings': 'TEXT', - } - - for col_name, col_type in new_columns.items(): - if col_name not in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};') - ) - - async def _drop_old_columns(self): - """Drop embedding_model_uuid and top_k columns (PostgreSQL only). - - SQLite does not support DROP COLUMN in older versions, so we leave the - columns in place — the SQLAlchemy entity simply won't map them. - """ - if self.ap.persistence_mgr.db.name != 'postgresql': - return - - columns = await self._get_table_columns('knowledge_bases') - - if 'embedding_model_uuid' in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN embedding_model_uuid;') - ) - - if 'top_k' in columns: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;') - ) - - async def _set_migration_flag(self): - """Set rag_plugin_migration_needed flag in metadata table.""" - # Check if the key already exists - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';") - ) - row = result.first() - if row is not None: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';") - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');") - ) - self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.') - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py b/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py deleted file mode 100644 index 59c1e357f..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm021_merge_exception_handling.py +++ /dev/null @@ -1,74 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(21) -class DBMigrateMergeExceptionHandling(migration.DBMigration): - """Merge hide-exception and block-failed-request-output into a single exception-handling select option, - and add failure-hint field. - - Conversion logic: - - block-failed-request-output=true -> exception-handling: hide - - hide-exception=true -> exception-handling: show-hint - - hide-exception=false -> exception-handling: show-error - """ - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'output' not in config: - config['output'] = {} - if 'misc' not in config['output']: - config['output']['misc'] = {} - - misc = config['output']['misc'] - - # Determine new exception-handling value from legacy fields - hide_exception = misc.get('hide-exception', True) - block_failed = misc.get('block-failed-request-output', False) - - if block_failed: - exception_handling = 'hide' - elif hide_exception: - exception_handling = 'show-hint' - else: - exception_handling = 'show-error' - - misc['exception-handling'] = exception_handling - - # Add failure-hint with default value - misc['failure-hint'] = 'Request failed.' - - # Remove legacy fields - misc.pop('hide-exception', None) - - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py b/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py deleted file mode 100644 index b66adc73b..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm022_monitoring_user_name.py +++ /dev/null @@ -1,73 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(22) -class DBMigrateMonitoringUserId(migration.DBMigration): - """Add user_id and user_name columns to monitoring_sessions table - - This migration adds the missing user_id column and also ensures user_name - column exists (in case migration 21 failed or was skipped). - """ - - async def _table_exists(self, table_name: str) -> bool: - """Check if a table exists (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);' - ).bindparams(table_name=table_name) - ) - return bool(result.scalar()) - else: - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams( - table_name=table_name - ) - ) - return result.first() is not None - - async def _get_table_columns(self, table_name: str) -> list[str]: - """Get column names from a table (works for both SQLite and PostgreSQL).""" - if self.ap.persistence_mgr.db.name == 'postgresql': - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;' - ).bindparams(table_name=table_name) - ) - return [row[0] for row in result.fetchall()] - else: - if not table_name.isidentifier(): - raise ValueError(f'Invalid table name: {table_name}') - result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});')) - return [row[1] for row in result.fetchall()] - - async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str): - """Add a column to a table if it does not already exist.""" - columns = await self._get_table_columns(table_name) - if column_name in columns: - self.ap.logger.debug('%s column already exists in %s.', column_name, table_name) - return - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};') - ) - self.ap.logger.info('Added %s column to %s table.', column_name, table_name) - - async def upgrade(self): - # Check if monitoring_sessions table exists - if not await self._table_exists('monitoring_sessions'): - self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.') - return - - # Add user_id column to monitoring_sessions table - await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)') - - # Add user_name column to monitoring_sessions table (in case migration 21 failed) - await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)') - - # Add user_name column to monitoring_messages table (in case migration 21 failed) - if await self._table_exists('monitoring_messages'): - await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)') - - async def downgrade(self): - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py b/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py deleted file mode 100644 index 11ab3bb4e..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm023_model_fallback_config.py +++ /dev/null @@ -1,102 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(23) -class DBMigrateModelFallbackConfig(migration.DBMigration): - """Convert model field from plain UUID string to object with primary/fallbacks""" - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'ai' not in config or 'local-agent' not in config['ai']: - continue - - local_agent = config['ai']['local-agent'] - changed = False - - # Convert model from string to object - model_value = local_agent.get('model', '') - if isinstance(model_value, str): - local_agent['model'] = { - 'primary': model_value, - 'fallbacks': [], - } - changed = True - - # Remove leftover fallback-models field if present - if 'fallback-models' in local_agent: - del local_agent['fallback-models'] - changed = True - - if not changed: - continue - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - - async def downgrade(self): - """Downgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines') - ) - pipelines = result.fetchall() - - current_version = self.ap.ver_mgr.get_current_version() - - for pipeline_row in pipelines: - uuid = pipeline_row[0] - config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1] - - if 'ai' not in config or 'local-agent' not in config['ai']: - continue - - local_agent = config['ai']['local-agent'] - - # Convert model from object back to string - model_value = local_agent.get('model', '') - if isinstance(model_value, dict): - local_agent['model'] = model_value.get('primary', '') - else: - continue - - # Update using raw SQL with compatibility for both SQLite and PostgreSQL - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text( - 'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid' - ), - {'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid}, - ) diff --git a/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py b/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py deleted file mode 100644 index a5b833bcc..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm024_wecombot_websocket_mode.py +++ /dev/null @@ -1,49 +0,0 @@ -from .. import migration - -import sqlalchemy -import json - - -@migration.migration_class(24) -class DBMigrateWecomBotWebSocketMode(migration.DBMigration): - """Add enable-webhook field to existing wecombot adapter configs. - - Existing wecombot bots were all using webhook mode, so we set - enable-webhook=true to preserve their behavior after the new - WebSocket long connection mode is introduced as default. - """ - - async def upgrade(self): - """Upgrade""" - result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'") - ) - bots = result.fetchall() - - for bot_row in bots: - bot_uuid = bot_row[0] - adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1] - - if 'enable-webhook' in adapter_config: - continue - - # Determine mode based on existing config: if webhook fields are present, keep webhook mode - has_webhook_config = bool( - adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid') - ) - adapter_config['enable-webhook'] = has_webhook_config - - if self.ap.persistence_mgr.db.name == 'postgresql': - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'), - {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, - ) - else: - await self.ap.persistence_mgr.execute_async( - sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'), - {'config': json.dumps(adapter_config), 'uuid': bot_uuid}, - ) - - async def downgrade(self): - """Downgrade""" - pass diff --git a/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py b/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py deleted file mode 100644 index 816bf2860..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm025_bot_pipeline_routing_rules.py +++ /dev/null @@ -1,15 +0,0 @@ -import sqlalchemy -from .. import migration - - -@migration.migration_class(25) -class DBMigrateBotPipelineRoutingRules(migration.DBMigration): - """Add pipeline_routing_rules column to bots table""" - - async def upgrade(self): - sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'") - await self.ap.persistence_mgr.execute_async(sql_text) - - async def downgrade(self): - sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules') - await self.ap.persistence_mgr.execute_async(sql_text) diff --git a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py b/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py deleted file mode 100644 index 8bd71b10c..000000000 --- a/src/langbot/pkg/persistence/migrations/dbm026_monitoring_tool_calls.py +++ /dev/null @@ -1,17 +0,0 @@ -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) diff --git a/src/langbot/pkg/pipeline/extension_preferences.py b/src/langbot/pkg/pipeline/extension_preferences.py new file mode 100644 index 000000000..42689b1e4 --- /dev/null +++ b/src/langbot/pkg/pipeline/extension_preferences.py @@ -0,0 +1,156 @@ +"""Validation and fail-closed reads for Pipeline extension preferences.""" + +from __future__ import annotations + +import typing + + +_BOOLEAN_FIELDS = ( + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + 'mcp_resource_agent_read_enabled', +) + + +def _valid_plugin_binding(value: typing.Any) -> bool: + return ( + isinstance(value, dict) + and isinstance(value.get('author'), str) + and bool(value['author']) + and isinstance(value.get('name'), str) + and bool(value['name']) + ) + + +def _valid_name(value: typing.Any) -> bool: + return isinstance(value, str) and bool(value) + + +def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]: + """Return a safe runtime view of persisted extension preferences. + + Missing fields in a valid object retain the established defaults. A + malformed root is treated as an empty allowlist with every extension + capability disabled. + """ + if not isinstance(value, dict): + return { + 'enable_all_plugins': False, + 'enable_all_mcp_servers': False, + 'enable_all_skills': False, + 'mcp_resource_agent_read_enabled': False, + 'plugins': [], + 'mcp_servers': [], + 'skills': [], + 'mcp_resources': [], + } + + normalized = dict(value) + normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True + normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True + normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True + normalized['mcp_resource_agent_read_enabled'] = ( + value.get('mcp_resource_agent_read_enabled', True) is True + ) + + plugins = value.get('plugins', []) + plugins_are_valid = isinstance(plugins, list) and all( + _valid_plugin_binding(plugin) for plugin in plugins + ) + normalized['plugins'] = list(plugins) if plugins_are_valid else [] + if not plugins_are_valid: + normalized['enable_all_plugins'] = False + + mcp_servers = value.get('mcp_servers', []) + mcp_servers_are_valid = isinstance(mcp_servers, list) and all( + _valid_name(server) for server in mcp_servers + ) + normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else [] + if not mcp_servers_are_valid: + normalized['enable_all_mcp_servers'] = False + + skills = value.get('skills', []) + skills_are_valid = isinstance(skills, list) and all(_valid_name(skill) for skill in skills) + normalized['skills'] = list(skills) if skills_are_valid else [] + if not skills_are_valid: + normalized['enable_all_skills'] = False + + mcp_resources = value.get('mcp_resources', []) + mcp_resources_are_valid = isinstance(mcp_resources, list) and all( + isinstance(resource, dict) for resource in mcp_resources + ) + normalized['mcp_resources'] = list(mcp_resources) if mcp_resources_are_valid else [] + if not mcp_resources_are_valid: + normalized['mcp_resource_agent_read_enabled'] = False + return normalized + + +def validate_extension_preferences( + value: typing.Any, + *, + context: str = 'Pipeline extensions_preferences', + field_aliases: typing.Mapping[str, str] | None = None, +) -> dict[str, typing.Any]: + """Reject extension preferences that cannot be consumed unambiguously.""" + if not isinstance(value, dict): + raise ValueError(f'{context} must be an object') + + for field_name in _BOOLEAN_FIELDS: + if field_name in value and not isinstance(value[field_name], bool): + raise ValueError(f"{context} field '{field_name}' must be a boolean") + + aliases = field_aliases or {} + _validate_list_field( + value, + 'plugins', + aliases.get('plugins', 'plugins'), + _valid_plugin_binding, + 'a plugin object with non-empty author and name', + context, + ) + _validate_list_field( + value, + 'mcp_servers', + aliases.get('mcp_servers', 'mcp_servers'), + _valid_name, + 'a non-empty string', + context, + ) + _validate_list_field( + value, + 'skills', + aliases.get('skills', 'skills'), + _valid_name, + 'a non-empty string', + context, + ) + _validate_list_field( + value, + 'mcp_resources', + aliases.get('mcp_resources', 'mcp_resources'), + lambda item: isinstance(item, dict), + 'an object', + context, + ) + return value + + +def _validate_list_field( + value: dict[str, typing.Any], + field_name: str, + field_label: str, + item_validator: typing.Callable[[typing.Any], bool], + item_description: str, + context: str, +) -> None: + if field_name not in value: + return + items = value[field_name] + if not isinstance(items, list): + raise ValueError(f"{context} field '{field_label}' must be a list") + for index, item in enumerate(items): + if not item_validator(item): + raise ValueError( + f"{context} field '{field_label}[{index}]' must be {item_description}" + ) diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index 634858e7c..ebc26cc66 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -14,7 +14,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.events as events from ..utils import importutil from .config_coercion import coerce_pipeline_config -from ..agent.runner.config_migration import ConfigMigration +from ..agent.runner.config_resolver import RunnerConfigResolver +from .extension_preferences import normalize_extension_preferences import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query @@ -92,14 +93,16 @@ class RuntimePipeline: self.stage_containers = stage_containers # Extract bound plugins and MCP servers from extensions_preferences - 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) + extensions_prefs = normalize_extension_preferences(pipeline_entity.extensions_preferences) + self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) is True + self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) is True pipeline_config = pipeline_entity.config or {} runner_config: dict[str, typing.Any] = {} - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None + runner_id = ( + RunnerConfigResolver.resolve_runner_id(pipeline_config) if isinstance(pipeline_config, dict) else None + ) if runner_id: - resolved_runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + resolved_runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) if isinstance(resolved_runner_config, dict): runner_config = resolved_runner_config @@ -107,24 +110,26 @@ class RuntimePipeline: 'mcp-resources', extensions_prefs.get('mcp_resources', []), ) - self.mcp_resource_agent_read_enabled = runner_config.get( - 'mcp-resource-agent-read-enabled', - extensions_prefs.get('mcp_resource_agent_read_enabled', True), + self.mcp_resource_agent_read_enabled = ( + runner_config.get( + 'mcp-resource-agent-read-enabled', + extensions_prefs.get('mcp_resource_agent_read_enabled', True), + ) + is True ) if self.enable_all_plugins: # None indicates to use all available plugins self.bound_plugins = None else: - plugin_list = extensions_prefs.get('plugins', []) - self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else [] + plugin_list = extensions_prefs['plugins'] + self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if self.enable_all_mcp_servers: # None indicates to use all available MCP servers self.bound_mcp_servers = None else: - mcp_server_list = extensions_prefs.get('mcp_servers', []) - self.bound_mcp_servers = mcp_server_list if mcp_server_list else [] + self.bound_mcp_servers = extensions_prefs['mcp_servers'] async def run(self, query: pipeline_query.Query): query.pipeline_config = self.pipeline_entity.config @@ -296,9 +301,9 @@ class RuntimePipeline: # Get runner name from pipeline config runner_name = None if query.pipeline_config: - from ..agent.runner.config_migration import ConfigMigration + from ..agent.runner.config_resolver import RunnerConfigResolver - runner_name = ConfigMigration.resolve_runner_id(query.pipeline_config) + runner_name = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) # Record query start and store message_id message_id = '' diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index c8109a4fa..9d5106083 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -11,8 +11,11 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.events as platform_events from ...agent.runner.descriptor import AgentRunnerDescriptor -from ...agent.runner.config_migration import ConfigMigration +from ...agent.runner.config_resolver import RunnerConfigResolver from ...agent.runner import config_schema +from ...agent.runner.resource_policy import ResourcePolicyProjector +from ...provider.tools.toolmgr import ToolManager +from ..extension_preferences import normalize_extension_preferences DEFAULT_PROMPT_CONFIG = [ @@ -77,6 +80,28 @@ class PreProcessor(stage.PipelineStage): self.ap.logger.warning(f'Fallback model {fallback_uuid} not found, skipping') return valid_fallbacks + async def _resolve_host_tools( + self, + query: pipeline_query.Query, + runner_config: dict[str, typing.Any], + bound_plugins: list[str] | None, + bound_mcp_servers: list[str] | None, + include_mcp_resource_tools: bool, + ) -> list[typing.Any]: + catalog = await self.ap.tool_mgr.get_resolved_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + policy = ResourcePolicyProjector.from_runner_config( + runner_config, + resolved_tool_names=ResourcePolicyProjector.extract_tool_names(catalog), + ) + catalog = ResourcePolicyProjector.filter_tools(catalog, policy) + ToolManager.bind_query_tool_sources(query, catalog) + return ToolManager.tools_from_catalog(catalog) + def _runner_accepts_multimodal_input(self, descriptor: AgentRunnerDescriptor | None) -> bool: if descriptor is None: return True @@ -136,9 +161,7 @@ class PreProcessor(stage.PipelineStage): strict_thread=True, ) except Exception as e: - self.ap.logger.warning( - f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}' - ) + self.ap.logger.warning(f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}') return None return messages or None @@ -168,14 +191,16 @@ class PreProcessor(stage.PipelineStage): ) -> entities.StageProcessResult: """Process""" # Resolve runner ID from the current ai.runner.id shape. - runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) # Get runner config from ai.runner_config[runner_id]. - runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + runner_config = ( + RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + ) query.variables = query.variables or {} bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) is True descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) session = await self.ap.sess_mgr.get_session(query) @@ -204,7 +229,7 @@ class PreProcessor(stage.PipelineStage): # been idle for longer than the configured conversation expire time. # The idle window is measured from the last preprocess/update time, not # from the conversation creation time. - conversation_expire_time = ConfigMigration.get_expire_time(query.pipeline_config) + conversation_expire_time = RunnerConfigResolver.get_expire_time(query.pipeline_config) now = datetime.datetime.now() if conversation_expire_time is not None and conversation_expire_time > 0: last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None) @@ -236,10 +261,12 @@ class PreProcessor(stage.PipelineStage): query.use_llm_model_uuid = llm_model.model_entity.uuid if uses_host_tools and 'func_call' in (llm_model.model_entity.abilities or []): - query.use_funcs = await self.ap.tool_mgr.get_all_tools( + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, bound_plugins, bound_mcp_servers, - include_mcp_resource_tools=include_mcp_resource_tools, + include_mcp_resource_tools, ) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') @@ -249,16 +276,20 @@ class PreProcessor(stage.PipelineStage): # If primary model doesn't support func_call but fallback models exist, # load tools anyway since fallback models may support them if uses_host_tools and not query.use_funcs and query.variables.get('_fallback_model_uuids'): - query.use_funcs = await self.ap.tool_mgr.get_all_tools( + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, bound_plugins, bound_mcp_servers, - include_mcp_resource_tools=include_mcp_resource_tools, + include_mcp_resource_tools, ) elif uses_host_tools: - query.use_funcs = await self.ap.tool_mgr.get_all_tools( + query.use_funcs = await self._resolve_host_tools( + query, + runner_config, bound_plugins, bound_mcp_servers, - include_mcp_resource_tools=include_mcp_resource_tools, + include_mcp_resource_tools, ) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') @@ -372,13 +403,13 @@ class PreProcessor(stage.PipelineStage): if getattr(self.ap, 'skill_mgr', None) is not None: pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) - extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {}) - enable_all_skills = extensions_prefs.get('enable_all_skills', True) + extensions_prefs = normalize_extension_preferences((pipeline_data or {}).get('extensions_preferences')) + enable_all_skills = extensions_prefs['enable_all_skills'] if enable_all_skills: bound_skills = None # None = all loaded skills are visible else: - bound_skills = extensions_prefs.get('skills', []) + bound_skills = extensions_prefs['skills'] query.variables['_pipeline_bound_skills'] = bound_skills diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py index ad80b6aae..51ea2aa04 100644 --- a/src/langbot/pkg/pipeline/process/handlers/chat.py +++ b/src/langbot/pkg/pipeline/process/handlers/chat.py @@ -12,7 +12,7 @@ from ... import entities from ... import plugin_diagnostics import langbot_plugin.api.entities.events as events -from ....agent.runner.config_migration import ConfigMigration +from ....agent.runner.config_resolver import RunnerConfigResolver from ....agent.runner import config_schema from ....utils import constants, runner as runner_utils from ....telemetry import features as telemetry_features @@ -312,8 +312,10 @@ class ChatMessageHandler(handler.MessageHandler): if prompt_config: return prompt_config - runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config) - runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config) + runner_config = ( + RunnerConfigResolver.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {} + ) bound_plugins = query.variables.get('_pipeline_bound_plugins', None) descriptor = await self._get_runner_descriptor(runner_id, bound_plugins) return config_schema.extract_prompt_config(descriptor, runner_config, DEFAULT_PROMPT_CONFIG) diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 87b56c092..8f8d155f9 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -15,14 +15,15 @@ from ..discover import engine from ..entity.persistence import bot as persistence_bot from ..entity.errors import platform as platform_errors +from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner.host_models import ( AgentBinding, AgentEventEnvelope, BindingScope, DeliveryPolicy, - ResourcePolicy, StatePolicy, ) +from ..agent.runner.resource_policy import ResourcePolicyProjector from .logger import EventLogger @@ -52,6 +53,8 @@ class SyntheticRouteTestAdapter: 'create_message_card', 'edit_message', 'delete_message', + 'add_reaction', + 'remove_reaction', 'forward_message', 'set_group_name', 'mute_member', @@ -985,6 +988,7 @@ class RuntimeBot: getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}' ) target_type, target_id, target_metadata = self._infer_reply_target(event) + supported_apis = self._get_adapter_supported_apis(adapter) conversation_id = None if target_type and target_id: @@ -1014,17 +1018,33 @@ class RuntimeBot: **target_metadata, }, supports_streaming=False, - supports_edit=False, - supports_reaction=False, + supports_edit='edit_message' in supported_apis, + supports_reaction=bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), platform_capabilities={ 'adapter': adapter.__class__.__name__, 'event_type': event_type, + 'supported_apis': supported_apis, }, ), raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None), data=self._compact_event_data(event), ) + @staticmethod + def _get_adapter_supported_apis( + adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + ) -> list[str]: + get_supported_apis = getattr(adapter, 'get_supported_apis', None) + if not callable(get_supported_apis): + return [] + try: + declared_apis = get_supported_apis() + except Exception: + return [] + if not isinstance(declared_apis, (list, tuple, set)): + return [] + return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name)) + @staticmethod def _agent_product_to_binding( agent: dict[str, typing.Any], @@ -1033,29 +1053,20 @@ class RuntimeBot: bot_uuid: str, ) -> AgentBinding | None: config = agent.get('config') if isinstance(agent, dict) else None - if not isinstance(config, dict): + if config is None: return None - runner = config.get('runner') - runner_id = None - if isinstance(runner, dict): - runner_id = runner.get('id') - runner_id = runner_id or agent.get('component_ref') + _, runner_id, runner_config = RunnerConfigResolver.resolve_agent_runner_config(config) if not runner_id: return None - runner_config_map = config.get('runner_config') - runner_config = {} - if isinstance(runner_config_map, dict): - runner_config = runner_config_map.get(runner_id) or {} - return AgentBinding( binding_id=f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}', scope=BindingScope(scope_type='bot', scope_id=bot_uuid), event_types=[event_type], runner_id=runner_id, runner_config=runner_config, - resource_policy=ResourcePolicy(), + resource_policy=ResourcePolicyProjector.from_runner_config(runner_config), state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True), enabled=True, @@ -1262,7 +1273,20 @@ class RuntimeBot: text=f'EBA event {event_type} target agent does not support this event: {target_uuid}', ) - binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid) + try: + binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid) + except Exception: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + failure_code='runner_failed', + reason='Agent configuration is invalid', + text=f'Failed to build Agent binding for EBA event {event_type}: {traceback.format_exc()}', + ) if binding is None: return await self._record_event_route_trace( event_type=event_type, diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index 2872b451c..fc6b980b0 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -27,7 +27,7 @@ from ..provider.modelmgr import requester as model_requester from ..core import app from ..utils import constants from ..agent.runner.session_registry import get_session_registry -from ..agent.runner.config_migration import ConfigMigration +from ..agent.runner.config_resolver import RunnerConfigResolver from ..agent.runner import config_schema @@ -37,6 +37,29 @@ from .agent_run_support import ( ) +_HOST_RESERVED_QUERY_VAR_PREFIXES = ( + '_host_', + '_pipeline_bound_', + '_pipeline_mcp_', + '_monitoring_', + '_sandbox_', + '_authorized', + '_permission', +) +_HOST_RESERVED_QUERY_VAR_KEYS = frozenset( + { + '_activated_skills', + '_fallback_model_uuids', + '_routed_by_rule', + } +) + + +def _is_host_reserved_query_var(key: str) -> bool: + """Return whether a Query variable controls Host authorization or runtime state.""" + return key in _HOST_RESERVED_QUERY_VAR_KEYS or key.startswith(_HOST_RESERVED_QUERY_VAR_PREFIXES) + + class _RawAction: def __init__(self, value: str): self.value = value @@ -105,11 +128,11 @@ async def _get_pipeline_knowledge_base_uuids(ap: app.Application, query: Any) -> if not pipeline_config: return [] - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) if not runner_id: return [] - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) registry = getattr(ap, 'agent_runner_registry', None) if registry is None: return [] @@ -189,6 +212,60 @@ async def _validate_run_authorization( return session, None +def _validate_frozen_tool_source_identity( + session: Any, + tool_name: str, + ap: app.Application, +) -> Union[tuple[None, handler.ActionResponse], tuple[dict[str, str | None], None]]: + """Resolve the exact Host tool implementation frozen for this run.""" + authorization = session.get('authorization') + resources = authorization.get('resources') if isinstance(authorization, dict) else None + tools = resources.get('tools') if isinstance(resources, dict) else None + matching_tools = ( + [tool for tool in tools if isinstance(tool, dict) and tool.get('tool_name') == tool_name] + if isinstance(tools, list) + else [] + ) + + source_ref: dict[str, str | None] | None = None + if len(matching_tools) == 1: + tool = matching_tools[0] + source = tool.get('source') + source_id = tool.get('source_id') + has_complete_shape = ( + 'source' in tool + and 'source_id' in tool + and isinstance(source, str) + and bool(source) + and source == source.strip() + and ( + source_id is None or (isinstance(source_id, str) and bool(source_id) and source_id == source_id.strip()) + ) + ) + if has_complete_shape: + if source in {'builtin', 'native', 'skill'} and source_id is None: + source_ref = {'source': source, 'source_id': None} + elif source == 'plugin' and isinstance(source_id, str): + source_ref = {'source': source, 'source_id': source_id} + elif source == 'mcp': + from ..provider.tools.loaders.mcp import MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE + + if isinstance(source_id, str) or tool_name in { + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + }: + source_ref = {'source': source, 'source_id': source_id} + + if source_ref is not None: + return source_ref, None + + run_id = session.get('run_id') + ap.logger.warning(f'TOOL: {tool_name} has an invalid frozen source identity for run_id {run_id}') + return None, handler.ActionResponse.error( + message=f'Tool {tool_name} has an invalid frozen source identity for this agent run', + ) + + def _get_cached_query(ap: app.Application, query_id: int | None) -> Any | None: """Return a cached Query for query-based runtime actions when available.""" if query_id is None: @@ -208,6 +285,8 @@ def _resolve_action_query(data: dict[str, Any], session: Any | None, ap: app.App if query_id is None: query_id = data.get('query_id') query = _get_cached_query(ap, query_id) + if query is None and session is not None: + query = session.get('execution_query') if query is not None and session is not None: object.__setattr__(query, '_agent_run_session', session) return query @@ -393,6 +472,16 @@ class RuntimeConnectionHandler(handler.Handler): query = self.ap.query_pool.cached_queries[query_id] + if not isinstance(key, str) or not key: + return handler.ActionResponse.error( + message='Query variable key must be a non-empty string', + ) + if _is_host_reserved_query_var(key): + self.ap.logger.warning(f'Plugin attempted to write Host-reserved Query variable {key!r}') + return handler.ActionResponse.error( + message=f'Query variable {key!r} is reserved for LangBot Host', + ) + query.variables[key] = value return handler.ActionResponse.success( @@ -760,6 +849,7 @@ class RuntimeConnectionHandler(handler.Handler): run_id = data.get('run_id') # Optional: present for AgentRunner calls caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation session = None + source_ref = None is_agent_runner_call = bool(run_id) if is_agent_runner_call: @@ -778,16 +868,24 @@ class RuntimeConnectionHandler(handler.Handler): ) if error: return error + source_ref, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap) + if error: + return error # Convert session_data to Session object (simplified) # In real implementation, you would reconstruct the full session # For now, we'll call the tool manager's execute method try: query = _resolve_action_query(data, session, self.ap) + execute_kwargs: dict[str, Any] = { + 'name': tool_name, + 'parameters': parameters, + 'query': query, + } + if source_ref is not None: + execute_kwargs['source_ref'] = source_ref result = await self.ap.tool_mgr.execute_func_call( - name=tool_name, - parameters=parameters, - query=query, + **execute_kwargs, ) if is_agent_runner_call: return handler.ActionResponse.success(data={'result': result}) @@ -810,6 +908,8 @@ class RuntimeConnectionHandler(handler.Handler): tool_name = data['tool_name'] run_id = data.get('run_id') # Optional: present for AgentRunner calls caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation + session = None + source_ref = None # Permission validation for AgentRunner calls if run_id: @@ -818,9 +918,15 @@ class RuntimeConnectionHandler(handler.Handler): ) if error: return error + source_ref, error = _validate_frozen_tool_source_identity(session, tool_name, self.ap) + if error: + return error try: - tool_detail = await self.ap.tool_mgr.get_tool_detail(tool_name) + detail_kwargs: dict[str, Any] = {} + if source_ref is not None: + detail_kwargs['source_ref'] = source_ref + tool_detail = await self.ap.tool_mgr.get_tool_detail(tool_name, **detail_kwargs) if tool_detail is None: return handler.ActionResponse.error( message=f'Tool {tool_name} not found', @@ -1310,7 +1416,7 @@ class RuntimeConnectionHandler(handler.Handler): Note: This action has dual validation paths: - AgentRunner: uses session_registry for permission check - - Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check + - Regular plugin: uses RunnerConfigResolver.resolve_runner_config for pipeline-level check """ kb_id = data['kb_id'] query_text = data['query_text'] diff --git a/src/langbot/pkg/provider/tools/errors.py b/src/langbot/pkg/provider/tools/errors.py index d44b39ba8..7206fe252 100644 --- a/src/langbot/pkg/provider/tools/errors.py +++ b/src/langbot/pkg/provider/tools/errors.py @@ -4,3 +4,12 @@ class ToolNotFoundError(ValueError): def __init__(self, name: str): self.name = name super().__init__(f'Tool not found: {name}') + + +class ToolExecutionDeniedError(PermissionError): + """Raised when a known tool is not allowed in the current execution context.""" + + def __init__(self, name: str, reason: str): + self.name = name + self.reason = reason + super().__init__(f'Tool execution denied for {name}: {reason}') diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 5b0a30610..7007042ce 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -22,6 +22,7 @@ from mcp.shared.exceptions import McpError from pydantic import AnyUrl from .. import loader +from ..errors import ToolExecutionDeniedError from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -1417,35 +1418,67 @@ class MCPLoader(loader.ToolLoader): return items - async def has_tool(self, name: str) -> bool: + def _session_by_source_id(self, source_id: str) -> RuntimeMCPSession | None: + return next( + (session for session in self.sessions.values() if session.server_uuid == source_id), + None, + ) + + async def has_tool(self, name: str, source_id: str | None = None) -> bool: """检查工具是否存在""" - if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): return bool(self._eligible_resource_sessions_for_bound(None)) - for session in self.sessions.values(): + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: return True return False - async def get_tool(self, name: str) -> resource_tool.LLMTool | None: - for session in self.sessions.values(): + async def get_tool( + self, + name: str, + source_id: str | None = None, + ) -> resource_tool.LLMTool | None: + if source_id is None and name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + if not self._eligible_resource_sessions_for_bound(None): + return None + return next( + (tool for tool in self._mcp_synthetic_resource_tools() if tool.name == name), + None, + ) + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: return function return None - async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def invoke_tool( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_id: str | None = None, + ) -> typing.Any: """执行工具调用""" - if name == MCP_TOOL_LIST_RESOURCES: - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: - return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + if source_id is None and name == MCP_TOOL_LIST_RESOURCES: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: + raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') return await self._invoke_mcp_list_resources(parameters, query) - if name == MCP_TOOL_READ_RESOURCE: - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: - return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + if source_id is None and name == MCP_TOOL_READ_RESOURCE: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: + raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled') return await self._invoke_mcp_read_resource(parameters, query) - for session in self.sessions.values(): + sessions = [self._session_by_source_id(source_id)] if source_id is not None else list(self.sessions.values()) + for session in sessions: + if session is None: + continue for function in session.get_tools(): if function.name == name: self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') @@ -1525,7 +1558,7 @@ class MCPLoader(loader.ToolLoader): default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES, ) -> str: """Build host-controlled MCP resource context for the current query.""" - if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True: return '' attachments = (query.variables or {}).get('_pipeline_mcp_resource_attachments', []) @@ -1543,7 +1576,7 @@ class MCPLoader(loader.ToolLoader): for raw_attachment in attachments: if remaining_tokens <= 0: break - if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False: + if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled', True) is not True: continue attachment = raw_attachment.copy() @@ -1561,8 +1594,23 @@ class MCPLoader(loader.ToolLoader): if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name: continue - max_tokens = min(int(attachment.get('max_tokens') or remaining_tokens), remaining_tokens) - max_bytes = int(attachment.get('max_bytes') or default_max_bytes) + raw_max_tokens = attachment.get('max_tokens') + raw_max_bytes = attachment.get('max_bytes') + if raw_max_tokens is None: + max_tokens = remaining_tokens + elif isinstance(raw_max_tokens, bool) or not isinstance(raw_max_tokens, int) or raw_max_tokens <= 0: + self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_tokens must be a positive integer') + continue + else: + max_tokens = min(raw_max_tokens, remaining_tokens) + + if raw_max_bytes is None: + max_bytes = default_max_bytes + elif isinstance(raw_max_bytes, bool) or not isinstance(raw_max_bytes, int) or raw_max_bytes <= 0: + self.ap.logger.warning(f'Ignoring MCP resource {uri!r}: max_bytes must be a positive integer') + continue + else: + max_bytes = raw_max_bytes try: envelope = await session.read_resource_envelope( diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py index 2697da44f..e444044d4 100644 --- a/src/langbot/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -50,17 +50,35 @@ class PluginToolLoader(loader.ToolLoader): return catalog - async def has_tool(self, name: str) -> bool: + async def get_tool( + self, + name: str, + source_id: str | None = None, + ) -> resource_tool.LLMTool | None: + tools = await self.get_tools([source_id] if source_id else None) + return next((tool for tool in tools if tool.name == name), None) + + async def has_tool(self, name: str, source_id: str | None = None) -> bool: """检查工具是否存在""" - for tool in await self.ap.plugin_connector.list_tools(): + for tool in await self.ap.plugin_connector.list_tools([source_id] if source_id else None): if tool.metadata.name == name: return True return False - async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def invoke_tool( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_id: str | None = None, + ) -> typing.Any: try: return await self.ap.plugin_connector.call_tool( - name, parameters, session=query.session, query_id=query.query_id + name, + parameters, + session=query.session, + query_id=query.query_id, + bound_plugins=[source_id] if source_id else None, ) except Exception as e: self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}') diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py index 716e0cab8..d013e25e4 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill.py +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -134,7 +134,11 @@ async def persist_activated_skill( if not isinstance(session, dict): return - state_context = session.get('state_context') + authorization = session.get('authorization') + if not isinstance(authorization, dict): + return + + state_context = authorization.get('state_context') if not isinstance(state_context, dict): return diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index b4463a1f1..24c1ff20f 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -20,6 +20,16 @@ if TYPE_CHECKING: ) +TOOL_SOURCE_REFS_QUERY_KEY = '_host_tool_source_refs' + + +class ToolSourceRef(typing.TypedDict): + """Stable Host-side identity for one tool implementation.""" + + source: str + source_id: str | None + + class ToolManager: """LLM工具管理器""" @@ -115,6 +125,121 @@ class ToolManager: return catalog + async def get_resolved_tool_catalog( + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = True, + include_mcp_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + """Return scoped tools with one unambiguous implementation per name. + + LLM tool calls only carry a function name. If two implementations with + the same name remain inside the current Host scope, choosing one by + loader or registration order would authorize one resource and execute + another. Such names are therefore omitted until the scope is narrowed. + """ + catalog = await self.get_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, + ) + tools_by_name: dict[str, list[dict[str, typing.Any]]] = {} + for item in catalog: + name = item.get('name') + if isinstance(name, str) and name: + tools_by_name.setdefault(name, []).append(item) + + resolved: list[dict[str, typing.Any]] = [] + for name, candidates in tools_by_name.items(): + implementations = { + (str(item.get('source') or ''), self._normalize_source_id(item.get('source_id'))) for item in candidates + } + if len(implementations) != 1: + self.ap.logger.warning( + f'Tool {name} is hidden because multiple implementations are visible: ' + f'{sorted(implementations, key=lambda item: (item[0], item[1] or ""))}' + ) + continue + resolved.append(candidates[0]) + return resolved + + @staticmethod + def _normalize_source_id(source_id: typing.Any) -> str | None: + return source_id if isinstance(source_id, str) and source_id else None + + @classmethod + def source_ref_from_catalog_item(cls, item: dict[str, typing.Any]) -> ToolSourceRef | None: + source = item.get('source') + if not isinstance(source, str) or not source: + return None + return { + 'source': source, + 'source_id': cls._normalize_source_id(item.get('source_id')), + } + + @classmethod + def source_refs_from_catalog( + cls, + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> dict[str, ToolSourceRef]: + refs: dict[str, ToolSourceRef] = {} + for item in catalog: + name = item.get('name') + ref = cls.source_ref_from_catalog_item(item) + if isinstance(name, str) and name and ref is not None: + refs[name] = ref + return refs + + @staticmethod + def tools_from_catalog( + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> list[resource_tool.LLMTool]: + """Materialize LLM schemas from an already authorized Host catalog.""" + return [ + resource_tool.LLMTool( + name=item['name'], + human_desc=item.get('human_desc') or item.get('description') or item['name'], + description=item.get('description') or '', + parameters=item.get('parameters') or {}, + func=lambda parameters: {}, + ) + for item in catalog + ] + + @classmethod + def bind_query_tool_sources( + cls, + query: pipeline_query.Query, + catalog: typing.Iterable[dict[str, typing.Any]], + ) -> None: + query.variables = query.variables or {} + query.variables[TOOL_SOURCE_REFS_QUERY_KEY] = cls.source_refs_from_catalog(catalog) + + @staticmethod + def get_query_tool_source( + query: pipeline_query.Query, + name: str, + ) -> ToolSourceRef | None: + variables = getattr(query, 'variables', None) + if not isinstance(variables, dict): + return None + refs = variables.get(TOOL_SOURCE_REFS_QUERY_KEY) + if not isinstance(refs, dict): + return None + ref = refs.get(name) + if not isinstance(ref, dict): + return None + source = ref.get('source') + if not isinstance(source, str) or not source: + return None + source_id = ref.get('source_id') + return { + 'source': source, + 'source_id': source_id if isinstance(source_id, str) and source_id else None, + } + 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 ( @@ -129,7 +254,11 @@ class ToolManager: return None - async def get_tool_schema(self, name: str) -> tuple[str | None, dict | None]: + async def get_tool_schema( + self, + name: str, + source_ref: ToolSourceRef | None = None, + ) -> tuple[str | None, dict | None]: """Return (description, parameters JSON schema) for a tool by name. Used by the host to prefill ToolResource so a runner can build LLM tool @@ -137,19 +266,23 @@ class ToolManager: return resource_tool.LLMTool, so no per-shape branching is needed. Returns (None, None) when the tool is not found. """ - tool = await self.get_tool_by_name(name) + tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name) if tool is None: return None, None return tool.description, (tool.parameters or None) - async def get_tool_detail(self, name: str) -> dict | None: + async def get_tool_detail( + self, + name: str, + source_ref: ToolSourceRef | None = None, + ) -> dict | None: """Return the host-level tool detail shape for a tool by name. All loaders return resource_tool.LLMTool, so the shape is uniform: {name, description, human_desc, parameters}. Returns None when the tool is not found. """ - tool = await self.get_tool_by_name(name) + tool = await self.get_tool_by_source(name, source_ref) if source_ref else await self.get_tool_by_name(name) if tool is None: return None return { @@ -159,6 +292,26 @@ class ToolManager: 'parameters': tool.parameters or {}, } + async def get_tool_by_source( + self, + name: str, + source_ref: ToolSourceRef, + ) -> tool_loader.ToolLookupResult | None: + """Resolve a tool only from the implementation frozen at authorization.""" + source = source_ref['source'] + source_id = source_ref.get('source_id') + if source in {'builtin', 'native'}: + return await self.native_tool_loader.get_tool(name) + if source == 'skill': + return await self.skill_tool_loader.get_tool(name) + if source == 'plugin': + if not source_id: + return None + return await self.plugin_tool_loader.get_tool(name, source_id=source_id) + if source == 'mcp': + return await self.mcp_tool_loader.get_tool(name, source_id=source_id) + return None + async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: tools = [] @@ -260,9 +413,58 @@ class ToolManager: ) return result - async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + async def execute_func_call( + self, + name: str, + parameters: dict, + query: pipeline_query.Query, + source_ref: ToolSourceRef | None = None, + ) -> typing.Any: from langbot.pkg.telemetry import features as telemetry_features + source_ref = source_ref or self.get_query_tool_source(query, name) + if source_ref is not None: + source = source_ref['source'] + source_id = source_ref.get('source_id') + uses_source_id = False + if source in {'builtin', 'native'}: + loader = self.native_tool_loader + telemetry_source = 'native' + exists = await loader.has_tool(name) + elif source == 'skill': + loader = self.skill_tool_loader + telemetry_source = 'skill' + exists = await loader.has_tool(name) + elif source == 'plugin' and source_id: + loader = self.plugin_tool_loader + telemetry_source = 'plugin' + uses_source_id = True + exists = await loader.has_tool(name, source_id=source_id) + elif source == 'mcp': + loader = self.mcp_tool_loader + telemetry_source = 'mcp' + uses_source_id = True + exists = await loader.has_tool(name, source_id=source_id) + else: + raise ToolNotFoundError(name) + + if not exists: + raise ToolNotFoundError(name) + + async def invoke_selected_tool() -> typing.Any: + if uses_source_id: + return await loader.invoke_tool(name, parameters, query, source_id=source_id) + return await loader.invoke_tool(name, parameters, query) + + telemetry_features.increment(query, 'tool_calls', telemetry_source) + return await self._invoke_tool_with_monitoring( + source=telemetry_source, + name=name, + parameters=parameters, + query=query, + invoke=invoke_selected_tool, + ) + if await self.native_tool_loader.has_tool(name): telemetry_features.increment(query, 'tool_calls', 'native') return await self._invoke_tool_with_monitoring( diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index 2af16486f..59cf86ca8 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -2,15 +2,6 @@ import langbot semantic_version = f'v{langbot.__version__}' -required_database_version = 25 -"""Tag the version of the legacy (3.x) database schema migration chain. - -Frozen at 25: the legacy ``pkg/persistence/migrations`` system (DBMigration / -dbmXXX_*.py) is deprecated and no longer accepts new migrations. All schema -changes from here on are managed by Alembic (see -``pkg/persistence/alembic/versions``). This value only gates the one-time -upgrade of pre-existing 3.x databases up to the Alembic baseline.""" - debug_mode = False edition = 'community' diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 53f71d182..099701ba1 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -37,12 +37,6 @@ system: max_bots: -1 max_pipelines: -1 max_extensions: -1 - # When set to a non-empty string, every pipeline is forced to use this - # Box sandbox-scope template regardless of its own configuration, and - # the per-pipeline "Sandbox Scope" selector is locked in the web UI. - # Used by SaaS deployments to confine a tenant to a single shared - # sandbox (set to '{global}'). Empty string = no restriction. - force_box_session_id_template: '' task_retention: # Keep at most this many completed async task records in memory completed_limit: 200 diff --git a/src/langbot/templates/legacy/command.json b/src/langbot/templates/legacy/command.json deleted file mode 100644 index 7c93f6470..000000000 --- a/src/langbot/templates/legacy/command.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "privilege": {}, - "command-prefix": [ - "!", - "!" - ] -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/pipeline.json b/src/langbot/templates/legacy/pipeline.json deleted file mode 100644 index 5b8fc9c9d..000000000 --- a/src/langbot/templates/legacy/pipeline.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "access-control":{ - "mode": "blacklist", - "blacklist": [], - "whitelist": [] - }, - "respond-rules": { - "default": { - "at": true, - "prefix": [ - "/ai", "!ai", "!ai", "ai" - ], - "regexp": [], - "random": 0.0 - } - }, - "income-msg-check": true, - "ignore-rules": { - "prefix": [], - "regexp": [] - }, - "check-sensitive-words": true, - "baidu-cloud-examine": { - "enable": false, - "api-key": "", - "api-secret": "" - }, - "rate-limit": { - "strategy": "drop", - "algo": "fixwin", - "fixwin": { - "default": { - "window-size": 60, - "limit": 60 - } - } - } -} diff --git a/src/langbot/templates/legacy/platform.json b/src/langbot/templates/legacy/platform.json deleted file mode 100644 index 3fa4e3bf7..000000000 --- a/src/langbot/templates/legacy/platform.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "platform-adapters": [ - { - "adapter": "nakuru", - "enable": false, - "host": "127.0.0.1", - "ws_port": 8080, - "http_port": 5700, - "token": "" - }, - { - "adapter": "aiocqhttp", - "enable": true, - "host": "0.0.0.0", - "port": 2280, - "access-token": "" - }, - { - "adapter": "qq-botpy", - "enable": false, - "appid": "", - "secret": "", - "intents": [ - "public_guild_messages", - "direct_message" - ] - }, - { - "adapter": "qqofficial", - "enable": false, - "appid": "1234567890", - "secret": "xxxxxxx", - "port": 2284, - "token": "abcdefg" - }, - { - "adapter": "wecom", - "enable": false, - "host": "0.0.0.0", - "port": 2290, - "corpid": "", - "secret": "", - "token": "", - "EncodingAESKey": "", - "contacts_secret": "" - }, - { - "adapter": "lark", - "enable": false, - "app_id": "cli_abcdefgh", - "app_secret": "XXXXXXXXXX", - "bot_name": "LangBot", - "enable-webhook": false, - "port": 2285, - "encrypt-key": "xxxxxxxxx" - }, - { - "adapter": "discord", - "enable": false, - "client_id": "1234567890", - "token": "XXXXXXXXXX" - }, - { - "adapter": "gewechat", - "enable": false, - "gewechat_url": "http://your-gewechat-server:2531", - "gewechat_file_url": "http://your-gewechat-server:2532", - "port": 2286, - "callback_url": "http://your-callback-url:2286/gewechat/callback", - "app_id": "", - "token": "" - }, - { - "adapter": "officialaccount", - "enable": false, - "token": "", - "EncodingAESKey": "", - "AppID": "", - "AppSecret": "", - "Mode": "drop", - "LoadingMessage": "AI正在思考中,请发送任意内容获取回复。", - "host": "0.0.0.0", - "port": 2287 - }, - { - "adapter": "dingtalk", - "enable": false, - "client_id": "", - "client_secret": "", - "robot_code": "", - "robot_name": "", - "markdown_card": false - }, - { - "adapter": "telegram", - "enable": false, - "token": "", - "markdown_card": false - }, - { - "adapter": "slack", - "enable": false, - "bot_token": "", - "signing_secret": "", - "port": 2288 - }, - { - "adapter": "wecomcs", - "enable": false, - "port": 2289, - "corpid": "", - "secret": "", - "token": "", - "EncodingAESKey": "" - } - ], - "track-function-calls": true, - "quote-origin": false, - "at-sender": false, - "force-delay": { - "min": 0, - "max": 0 - }, - "long-text-process": { - "threshold": 2560, - "strategy": "forward", - "font-path": "" - }, - "hide-exception-info": true -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/provider.json b/src/langbot/templates/legacy/provider.json deleted file mode 100644 index 75b107f5d..000000000 --- a/src/langbot/templates/legacy/provider.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "enable-chat": true, - "enable-vision": true, - "keys": { - "openai": [ - "sk-1234567890" - ], - "anthropic": [ - "sk-1234567890" - ], - "moonshot": [ - "sk-1234567890" - ], - "deepseek": [ - "sk-1234567890" - ], - "gitee-ai": [ - "XXXXX" - ], - "xai": [ - "xai-1234567890" - ], - "zhipuai": [ - "xxxxxxx" - ], - "siliconflow": [ - "xxxxxxx" - ], - "bailian": [ - "sk-xxxxxxx" - ], - "volcark": [ - "xxxxxxxx" - ], - "modelscope": [ - "xxxxxxxx" - ], - "ppio": [ - "xxxxxxxx" - ] - }, - "requester": { - "openai-chat-completions": { - "base-url": "https://api.openai.com/v1", - "args": {}, - "timeout": 120 - }, - "anthropic-messages": { - "base-url": "https://api.anthropic.com", - "args": { - "max_tokens": 1024 - }, - "timeout": 120 - }, - "moonshot-chat-completions": { - "base-url": "https://api.moonshot.cn/v1", - "args": {}, - "timeout": 120 - }, - "deepseek-chat-completions": { - "base-url": "https://api.deepseek.com", - "args": {}, - "timeout": 120 - }, - "ollama-chat": { - "base-url": "http://127.0.0.1:11434", - "args": {}, - "timeout": 600 - }, - "gitee-ai-chat-completions": { - "base-url": "https://ai.gitee.com/v1", - "args": {}, - "timeout": 120 - }, - "xai-chat-completions": { - "base-url": "https://api.x.ai/v1", - "args": {}, - "timeout": 120 - }, - "zhipuai-chat-completions": { - "base-url": "https://open.bigmodel.cn/api/paas/v4", - "args": {}, - "timeout": 120 - }, - "lmstudio-chat-completions": { - "base-url": "http://127.0.0.1:1234/v1", - "args": {}, - "timeout": 120 - }, - "siliconflow-chat-completions": { - "base-url": "https://api.siliconflow.cn/v1", - "args": {}, - "timeout": 120 - }, - "bailian-chat-completions": { - "args": {}, - "base-url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "timeout": 120 - }, - "volcark-chat-completions": { - "args": {}, - "base-url": "https://ark.cn-beijing.volces.com/api/v3", - "timeout": 120 - }, - "modelscope-chat-completions": { - "base-url": "https://api-inference.modelscope.cn/v1", - "args": {}, - "timeout": 120 - }, - "ppio-chat-completions": { - "base-url": "https://api.ppinfra.com/v3/openai", - "args": {}, - "timeout": 120 - } - }, - "model": "gpt-4o", - "prompt-mode": "normal", - "prompt": { - "default": "You are a helpful assistant." - }, - "runner": "local-agent", - "dify-service-api": { - "base-url": "https://api.dify.ai/v1", - "app-type": "chat", - "options": { - "convert-thinking-tips": "plain" - }, - "chat": { - "api-key": "app-1234567890", - "timeout": 120 - }, - "agent": { - "api-key": "app-1234567890", - "timeout": 120 - }, - "workflow": { - "api-key": "app-1234567890", - "output-key": "summary", - "timeout": 120 - } - }, - "dashscope-app-api": { - "app-type": "agent", - "api-key": "sk-1234567890", - "agent": { - "app-id": "Your_app_id", - "references_quote": "参考资料来自:" - }, - "workflow": { - "app-id": "Your_app_id", - "references_quote": "参考资料来自:", - "biz_params": { - "city": "北京", - "date": "2023-08-10" - } - } - }, - "mcp": { - "servers": [] - } -} \ No newline at end of file diff --git a/src/langbot/templates/legacy/system.json b/src/langbot/templates/legacy/system.json deleted file mode 100644 index f3e69feb5..000000000 --- a/src/langbot/templates/legacy/system.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "admin-sessions": [], - "network-proxies": { - "http": null, - "https": null - }, - "report-usage": true, - "logging-level": "info", - "session-concurrency": { - "default": 1 - }, - "pipeline-concurrency": 20, - "qcg-center-url": "https://api.qchatgpt.rockchin.top/api/v2", - "help-message": "LangBot - 😎高稳定性、🧩支持插件、🌏实时联网的 ChatGPT QQ 机器人🤖\n链接:https://q.rkcn.top", - "http-api": { - "enable": true, - "host": "0.0.0.0", - "port": 5300, - "jwt-expire": 604800 - }, - "persistence": { - "sqlite": { - "path": "data/langbot.db" - }, - "use": "sqlite" - } -} \ No newline at end of file diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 25d3e5c82..8084c805c 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -9,34 +9,60 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q from __future__ import annotations -import pytest -from sqlalchemy.ext.asyncio import create_async_engine +import json -from langbot.pkg.entity.persistence.base import Base -from langbot.pkg.persistence.alembic_runner import ( - run_alembic_upgrade, - run_alembic_stamp, - get_alembic_current, - _ALEMBIC_DIR, -) +import pytest +import sqlalchemy as sa from alembic.config import Config from alembic.script import ScriptDirectory +from sqlalchemy.ext.asyncio import create_async_engine + +from langbot.pkg.entity.persistence import ( + agent as agent_models, + agent_run as agent_run_models, + agent_runner_state as agent_runner_state_models, + bot as bot_models, + metadata as metadata_models, + monitoring as monitoring_models, +) +from langbot.pkg.entity.persistence.base import Base +from langbot.pkg.persistence.alembic_runner import ( + _ALEMBIC_DIR, + get_alembic_current, + run_alembic_stamp, + run_alembic_upgrade, +) + + +def _get_script_directory() -> ScriptDirectory: + """Load the repository's Alembic revision graph.""" + cfg = Config() + cfg.set_main_option('script_location', _ALEMBIC_DIR) + return ScriptDirectory.from_config(cfg) def _get_script_head() -> str: - """Resolve the current Alembic head revision from the script directory. - - Avoids hardcoding a revision number in assertions so adding a new - migration doesn't require editing the migration tests. - """ - cfg = Config() - cfg.set_main_option('script_location', _ALEMBIC_DIR) - return ScriptDirectory.from_config(cfg).get_current_head() + """Resolve the only Alembic head without hardcoding a revision.""" + return _get_script_directory().get_current_head() pytestmark = pytest.mark.integration +class TestAlembicRevisionGraph: + """Static release gates for the Alembic graph.""" + + def test_revision_ids_fit_alembic_version_column_and_graph_has_one_head(self): + script = _get_script_directory() + revisions = list(script.walk_revisions()) + + assert script.get_bases() == ['0001_baseline'] + assert script.get_heads() == ['0012_monitoring_tool_calls'] + assert all(len(item.revision) <= 32 for item in revisions), { + item.revision: len(item.revision) for item in revisions if len(item.revision) > 32 + } + + @pytest.fixture def sqlite_db_url(tmp_path): """Create SQLite URL with temporary database file.""" @@ -149,6 +175,174 @@ class TestSQLiteMigrationUpgrade: rev2 = await get_alembic_current(sqlite_engine) assert rev2 == rev1, f'Expected {rev1}, got {rev2}' + @pytest.mark.asyncio + async def test_upgrade_from_mcp_resource_branch_creates_agent_and_monitoring_schema(self, sqlite_engine): + """The MCP branch can converge into the Agent branch and the current head.""" + await run_alembic_stamp(sqlite_engine, '0008_mcp_resource_prefs') + await run_alembic_upgrade(sqlite_engine, 'head') + + def inspect_schema(sync_conn): + inspector = sa.inspect(sync_conn) + tables = set(inspector.get_table_names()) + monitoring_indexes = { + index['name'] for index in inspector.get_indexes(monitoring_models.MonitoringToolCall.__tablename__) + } + return tables, monitoring_indexes + + async with sqlite_engine.connect() as conn: + tables, monitoring_indexes = await conn.run_sync(inspect_schema) + + expected_agent_tables = { + agent_models.Agent.__tablename__, + agent_run_models.AgentRun.__tablename__, + agent_run_models.AgentRunEvent.__tablename__, + agent_run_models.AgentRuntime.__tablename__, + agent_runner_state_models.AgentRunnerState.__tablename__, + } + expected_monitoring_indexes = {index.name for index in monitoring_models.MonitoringToolCall.__table__.indexes} + + assert expected_agent_tables <= tables + assert monitoring_models.MonitoringToolCall.__tablename__ in tables + assert expected_monitoring_indexes <= monitoring_indexes + assert await get_alembic_current(sqlite_engine) == _get_script_head() + + @pytest.mark.asyncio + async def test_bot_admin_data_migrates_when_create_all_already_created_table(self, sqlite_engine): + """0007 must migrate config admins even when the ORM created its table first.""" + config = { + 'admins': ['group_admin-1', 'person_user_with_underscore', 'malformed'], + 'preserved': True, + } + + async with sqlite_engine.begin() as conn: + await conn.run_sync(bot_models.Bot.__table__.create) + await conn.run_sync(bot_models.BotAdmin.__table__.create) + await conn.run_sync(metadata_models.Metadata.__table__.create) + await conn.execute( + sa.insert(bot_models.Bot).values( + uuid='bot-1', + name='Bot', + description='', + adapter='test', + adapter_config={}, + enable=True, + ) + ) + await conn.execute( + sa.insert(bot_models.BotAdmin).values( + bot_uuid='bot-1', + launcher_type='group', + launcher_id='admin-1', + ) + ) + await conn.execute( + sa.insert(metadata_models.Metadata).values( + key='instance_config', + value=json.dumps(config), + ) + ) + + await run_alembic_stamp(sqlite_engine, '0006_normalize_mcp_remote_mode') + await run_alembic_upgrade(sqlite_engine, '0007_add_bot_admins') + + async with sqlite_engine.connect() as conn: + admin_rows = ( + await conn.execute( + sa.select( + bot_models.BotAdmin.launcher_type, + bot_models.BotAdmin.launcher_id, + ).order_by(bot_models.BotAdmin.launcher_type, bot_models.BotAdmin.launcher_id) + ) + ).all() + stored_config = ( + await conn.execute( + sa.select(metadata_models.Metadata.value).where(metadata_models.Metadata.key == 'instance_config') + ) + ).scalar_one() + + assert admin_rows == [('group', 'admin-1'), ('person', 'user_with_underscore')] + assert json.loads(stored_config) == {'preserved': True} + + @pytest.mark.asyncio + async def test_pipeline_routing_rules_preserve_message_filters(self, sqlite_engine): + """Every legacy routing rule keeps its matching semantics in event bindings.""" + routing_rules = [ + { + 'type': 'launcher_type', + 'operator': 'eq', + 'value': 'group', + 'pipeline_uuid': 'pipeline-group', + }, + { + 'type': 'launcher_id', + 'operator': 'regex', + 'value': '^room-', + 'pipeline_uuid': 'pipeline-room', + }, + { + 'type': 'message_content', + 'operator': 'contains', + 'value': 'urgent', + 'pipeline_uuid': 'pipeline-content', + }, + { + 'type': 'message_has_element', + 'operator': 'eq', + 'value': 'Image', + 'pipeline_uuid': 'pipeline-image', + }, + { + 'type': 'message_has_element', + 'operator': 'neq', + 'value': 'Voice', + 'pipeline_uuid': 'pipeline-no-voice', + }, + ] + + async with sqlite_engine.begin() as conn: + await conn.execute( + sa.text( + 'CREATE TABLE bots (' + 'uuid VARCHAR(255) PRIMARY KEY, ' + 'use_pipeline_uuid VARCHAR(255), ' + 'pipeline_routing_rules JSON NOT NULL, ' + 'event_bindings JSON NOT NULL' + ')' + ) + ) + await conn.execute( + sa.text( + 'INSERT INTO bots ' + '(uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings) ' + 'VALUES (:uuid, :default_pipeline, :rules, :bindings)' + ), + { + 'uuid': 'bot-routing', + 'default_pipeline': 'pipeline-default', + 'rules': json.dumps(routing_rules), + 'bindings': '[]', + }, + ) + + await run_alembic_stamp(sqlite_engine, '0008_agent_product_surface') + await run_alembic_upgrade(sqlite_engine, '0009_migrate_event_bindings') + + async with sqlite_engine.connect() as conn: + raw_bindings = ( + await conn.execute(sa.text("SELECT event_bindings FROM bots WHERE uuid = 'bot-routing'")) + ).scalar_one() + + bindings = json.loads(raw_bindings) + filters_by_pipeline = {binding['target_uuid']: binding['filters'] for binding in bindings} + assert filters_by_pipeline == { + 'pipeline-group': [{'field': 'chat_type', 'operator': 'eq', 'value': 'group'}], + 'pipeline-room': [{'field': 'chat_id', 'operator': 'regex', 'value': '^room-'}], + 'pipeline-content': [{'field': 'message_text', 'operator': 'contains', 'value': 'urgent'}], + 'pipeline-image': [{'field': 'message_element_types', 'operator': 'contains', 'value': 'Image'}], + 'pipeline-no-voice': [{'field': 'message_element_types', 'operator': 'not_contains', 'value': 'Voice'}], + 'pipeline-default': [], + } + class TestSQLiteMigrationFreshDatabase: """Tests for fresh database workflow.""" diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py index 362586111..42efad609 100644 --- a/tests/integration/pipeline/test_full_flow.py +++ b/tests/integration/pipeline/test_full_flow.py @@ -31,7 +31,7 @@ def mock_circular_import_chain(): """ Break circular import chain for pipeline modules using isolated_sys_modules. - Chain: pipeline → core.app → provider.runner → http_controller → groups/plugins + Chain: pipeline → core.app → http_controller → groups/plugins We mock minimal modules to allow importing RuntimePipeline, StageInstContainer, and stage classes without triggering full application initialization. @@ -47,7 +47,7 @@ def mock_circular_import_chain(): # Mock core.app - Application class is referenced but not instantiated mock_core_app = Mock() - # Mock utils.importutil - prevents auto-import of runners + # Mock utils.importutil to avoid unrelated import-time registrations. mock_importutil = Mock() mock_importutil.import_modules_in_pkg = lambda pkg: None mock_importutil.import_modules_in_pkgs = lambda pkgs: None @@ -63,14 +63,13 @@ def mock_circular_import_chain(): 'langbot.pkg.pipeline.process.handlers.chat', 'langbot.pkg.pipeline.process.handlers.command', 'langbot.pkg.pipeline.respback.respback', - 'langbot.pkg.provider.runner', ] with isolated_sys_modules( mocks={ 'langbot.pkg.core.entities': mock_core_entities, 'langbot.pkg.core.app': mock_core_app, - 'langbot.pkg.utils.importutil': mock_importutil, + 'langbot.pkg.utils.importutil': mock_importutil, 'langbot.pkg.pipeline.controller': Mock(), 'langbot.pkg.pipeline.pipelinemgr': Mock(), }, @@ -249,9 +248,7 @@ def set_fake_runner(pipeline_app): yield result orchestrator.run_from_query = run_from_query - orchestrator.resolve_runner_id_for_telemetry = Mock( - return_value='plugin:langbot-team/LocalAgent/default' - ) + orchestrator.resolve_runner_id_for_telemetry = Mock(return_value='plugin:langbot-team/LocalAgent/default') pipeline_app.agent_run_orchestrator = orchestrator return _set_runner diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py index f4cba930d..7a61101d7 100644 --- a/tests/unit_tests/agent/conftest.py +++ b/tests/unit_tests/agent/conftest.py @@ -1,4 +1,5 @@ """Shared test fixtures for agent runner tests.""" + from __future__ import annotations import typing @@ -45,6 +46,7 @@ def make_session( available_apis: dict[str, bool] | None = None, state_policy: dict[str, typing.Any] | None = None, state_context: dict[str, typing.Any] | None = None, + execution_query: typing.Any | None = None, ) -> dict[str, typing.Any]: """Create a minimal AgentRunSession dict for testing. @@ -59,13 +61,12 @@ def make_session( AgentRunSession dict with run-scoped authorization snapshot """ import time + now = int(time.time()) res = resources if resources is not None else make_resources() apis = available_apis if available_apis is not None else {} policy = ( - state_policy - if state_policy is not None - else {'enable_state': True, 'state_scopes': ['conversation', 'actor']} + state_policy if state_policy is not None else {'enable_state': True, 'state_scopes': ['conversation', 'actor']} ) context = state_context if state_context is not None else {} @@ -102,6 +103,7 @@ def make_session( 'run_id': run_id, 'runner_id': runner_id, 'query_id': query_id, + 'execution_query': execution_query, 'plugin_identity': plugin_identity, 'authorization': { 'resources': res, diff --git a/tests/unit_tests/agent/test_chat_handler.py b/tests/unit_tests/agent/test_chat_handler.py index 7b98c51ad..db50984d1 100644 --- a/tests/unit_tests/agent/test_chat_handler.py +++ b/tests/unit_tests/agent/test_chat_handler.py @@ -8,6 +8,7 @@ Tests focus on: Avoids circular imports by using proper import structure. """ + from __future__ import annotations import uuid @@ -19,11 +20,12 @@ from langbot.pkg.agent.runner.errors import ( RunnerExecutionError, RunnerNotAuthorizedError, ) -from langbot.pkg.agent.runner.config_migration import ConfigMigration +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver # Define mock classes in dependency order (no forward references needed) + class MockLauncherType: value = 'person' @@ -59,6 +61,7 @@ class MockSession: class MockQuery: """Mock Query for testing.""" + def __init__(self): self.query_id = 1 self.launcher_type = MockLauncherType() @@ -93,6 +96,7 @@ class MockQuery: class MockMessageChunk: """Mock MessageChunk for testing.""" + def __init__(self, content, resp_message_id=None): self.role = 'assistant' self.content = content @@ -106,6 +110,7 @@ class MockMessageChunk: class MockEventContext: """Mock event context for testing.""" + def __init__(self, prevented=False, reply_message_chain=None, user_message_alter=None): self._prevented = prevented self.event = MagicMock() @@ -118,6 +123,7 @@ class MockEventContext: class MockAgentRunOrchestrator: """Mock AgentRunOrchestrator for testing.""" + def __init__(self, chunks=None, error=None): self._chunks = chunks or [] self._error = error @@ -138,6 +144,7 @@ class MockAgentRunOrchestrator: class MockApplication: """Mock Application for testing.""" + def __init__(self, orchestrator=None): self.agent_run_orchestrator = orchestrator or MockAgentRunOrchestrator() self.logger = MagicMock() @@ -226,11 +233,11 @@ class TestStreamingBehavior: assert len(resp_messages) == 2 -class TestConfigMigrationInChatHandler: - """Tests for ConfigMigration usage in chat handler context.""" +class TestRunnerConfigResolverInChatHandler: + """Tests for RunnerConfigResolver usage in chat handler context.""" def test_resolve_runner_id_from_pipeline_config(self): - """Chat handler should use ConfigMigration to resolve runner ID.""" + """Chat handler should use RunnerConfigResolver to resolve runner ID.""" pipeline_config = { 'ai': { 'runner': { @@ -239,7 +246,7 @@ class TestConfigMigrationInChatHandler: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) assert runner_id == 'plugin:langbot-team/LocalAgent/default' def test_old_runner_field_is_not_resolved(self): @@ -252,7 +259,7 @@ class TestConfigMigrationInChatHandler: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) assert runner_id is None @@ -302,19 +309,23 @@ class TestChatHandlerImports: """Import chat handler module should work.""" # This test verifies the import works without circular dependency from langbot.pkg.pipeline.process.handlers import chat + assert chat.ChatMessageHandler is not None def test_chat_handler_class_exists(self): """ChatMessageHandler class should be defined.""" from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + assert ChatMessageHandler.__name__ == 'ChatMessageHandler' def test_chat_handler_has_handle_method(self): """ChatMessageHandler should have async generator handle method.""" from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler + assert hasattr(ChatMessageHandler, 'handle') # handle returns AsyncGenerator, so check for async generator function import inspect + assert inspect.isasyncgenfunction(ChatMessageHandler.handle) @@ -353,8 +364,10 @@ class TestChatHandlerAsyncBehavior: def make_result(*args, **kwargs): return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -396,8 +409,10 @@ class TestChatHandlerAsyncBehavior: def make_result(*args, **kwargs): return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -439,8 +454,10 @@ class TestChatHandlerAsyncBehavior: def make_result(*args, **kwargs): return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -460,9 +477,7 @@ class TestChatHandlerAsyncBehavior: from langbot.pkg.pipeline.process.handlers.chat import ChatMessageHandler from langbot.pkg.pipeline import entities - orchestrator = MockAgentRunOrchestrator( - error=RunnerNotFoundError('plugin:notexist/unknown/default') - ) + orchestrator = MockAgentRunOrchestrator(error=RunnerNotFoundError('plugin:notexist/unknown/default')) mock_ap = MockApplication(orchestrator=orchestrator) mock_ap.plugin_connector.emit_event = AsyncMock(return_value=MockEventContext(prevented=False)) @@ -479,8 +494,10 @@ class TestChatHandlerAsyncBehavior: user_notice=kwargs.get('user_notice'), ) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -518,8 +535,10 @@ class TestChatHandlerAsyncBehavior: user_notice=kwargs.get('user_notice'), ) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -556,8 +575,10 @@ class TestChatHandlerAsyncBehavior: user_notice=kwargs.get('user_notice'), ) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event @@ -593,8 +614,10 @@ class TestChatHandlerAsyncBehavior: def make_result(*args, **kwargs): return MagicMock(result_type=kwargs.get('result_type', entities.ResultType.CONTINUE)) - with patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, \ - patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result): + with ( + patch('langbot.pkg.pipeline.process.handlers.chat.events') as mock_events_module, + patch('langbot.pkg.pipeline.entities.StageProcessResult', side_effect=make_result), + ): mock_events_module.PersonNormalMessageReceived = mock_event mock_events_module.GroupNormalMessageReceived = mock_event diff --git a/tests/unit_tests/agent/test_config_migration.py b/tests/unit_tests/agent/test_config_migration.py deleted file mode 100644 index 41e142d5e..000000000 --- a/tests/unit_tests/agent/test_config_migration.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Tests for current AgentRunner config helpers.""" - -from __future__ import annotations - -from langbot.pkg.agent.runner.config_migration import ConfigMigration - - -class TestResolveRunnerId: - """Tests for ConfigMigration.resolve_runner_id.""" - - def test_resolve_current_runner_id(self): - pipeline_config = { - 'ai': { - 'runner': { - 'id': 'plugin:langbot-team/LocalAgent/default', - }, - }, - } - - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - assert runner_id == 'plugin:langbot-team/LocalAgent/default' - - def test_does_not_resolve_legacy_runner_field(self): - pipeline_config = { - 'ai': { - 'runner': { - 'runner': 'local-agent', - }, - }, - } - - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - assert runner_id is None - - def test_resolve_no_runner_config(self): - runner_id = ConfigMigration.resolve_runner_id({}) - assert runner_id is None - - -class TestResolveRunnerConfig: - """Tests for ConfigMigration.resolve_runner_config.""" - - def test_resolve_current_config(self): - pipeline_config = { - 'ai': { - 'runner_config': { - 'plugin:langbot-team/LocalAgent/default': { - 'model': 'uuid-123', - 'custom_option': 10, - }, - }, - }, - } - - config = ConfigMigration.resolve_runner_config( - pipeline_config, - 'plugin:langbot-team/LocalAgent/default', - ) - assert config == {'model': 'uuid-123', 'custom_option': 10} - - def test_does_not_read_legacy_runner_block(self): - pipeline_config = { - 'ai': { - 'local-agent': { - 'model': 'uuid-123', - }, - }, - } - - config = ConfigMigration.resolve_runner_config( - pipeline_config, - 'plugin:langbot-team/LocalAgent/default', - ) - assert config == {} - - def test_resolve_no_config(self): - config = ConfigMigration.resolve_runner_config( - {}, - 'plugin:langbot-team/LocalAgent/default', - ) - assert config == {} - - -class TestGetExpireTime: - """Tests for ConfigMigration.get_expire_time.""" - - def test_get_expire_time_zero(self): - pipeline_config = { - 'ai': { - 'runner': { - 'expire-time': 0, - }, - }, - } - - expire_time = ConfigMigration.get_expire_time(pipeline_config) - assert expire_time == 0 - - def test_get_expire_time_positive(self): - pipeline_config = { - 'ai': { - 'runner': { - 'expire-time': 3600, - }, - }, - } - - expire_time = ConfigMigration.get_expire_time(pipeline_config) - assert expire_time == 3600 - - def test_get_expire_time_default(self): - expire_time = ConfigMigration.get_expire_time({}) - assert expire_time == 0 diff --git a/tests/unit_tests/agent/test_config_resolver.py b/tests/unit_tests/agent/test_config_resolver.py new file mode 100644 index 000000000..19b163fd3 --- /dev/null +++ b/tests/unit_tests/agent/test_config_resolver.py @@ -0,0 +1,207 @@ +"""Tests for current AgentRunner config resolution.""" + +from __future__ import annotations + +import pytest + +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver + + +class TestResolveRunnerId: + """Tests for RunnerConfigResolver.resolve_runner_id.""" + + def test_resolve_current_runner_id(self): + pipeline_config = { + 'ai': { + 'runner': { + 'id': 'plugin:langbot-team/LocalAgent/default', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id == 'plugin:langbot-team/LocalAgent/default' + + def test_does_not_resolve_legacy_runner_field(self): + pipeline_config = { + 'ai': { + 'runner': { + 'runner': 'local-agent', + }, + }, + } + + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + assert runner_id is None + + def test_resolve_no_runner_config(self): + runner_id = RunnerConfigResolver.resolve_runner_id({}) + assert runner_id is None + + +class TestResolveRunnerConfig: + """Tests for RunnerConfigResolver.resolve_runner_config.""" + + def test_resolve_current_config(self): + pipeline_config = { + 'ai': { + 'runner_config': { + 'plugin:langbot-team/LocalAgent/default': { + 'model': 'uuid-123', + 'custom_option': 10, + }, + }, + }, + } + + config = RunnerConfigResolver.resolve_runner_config( + pipeline_config, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {'model': 'uuid-123', 'custom_option': 10} + + def test_does_not_read_legacy_runner_block(self): + pipeline_config = { + 'ai': { + 'local-agent': { + 'model': 'uuid-123', + }, + }, + } + + config = RunnerConfigResolver.resolve_runner_config( + pipeline_config, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {} + + def test_resolve_no_config(self): + config = RunnerConfigResolver.resolve_runner_config( + {}, + 'plugin:langbot-team/LocalAgent/default', + ) + assert config == {} + + +class TestGetExpireTime: + """Tests for RunnerConfigResolver.get_expire_time.""" + + def test_get_expire_time_zero(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 0, + }, + }, + } + + expire_time = RunnerConfigResolver.get_expire_time(pipeline_config) + assert expire_time == 0 + + def test_get_expire_time_positive(self): + pipeline_config = { + 'ai': { + 'runner': { + 'expire-time': 3600, + }, + }, + } + + expire_time = RunnerConfigResolver.get_expire_time(pipeline_config) + assert expire_time == 3600 + + +class TestValidateCurrentConfig: + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + def test_agent_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value): + config = { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + field_name: invalid_value, + } + }, + } + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + RunnerConfigResolver.validate_agent_config(config) + + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + def test_pipeline_rejects_non_boolean_selected_runner_security_fields(self, field_name, invalid_value): + config = { + 'ai': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + field_name: invalid_value, + } + }, + } + } + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + RunnerConfigResolver.validate_pipeline_config(config) + + def test_only_selected_runner_security_fields_are_validated(self): + config = { + 'runner': {'id': 'plugin:test/selected/default'}, + 'runner_config': { + 'plugin:test/selected/default': {'enable-all-tools': False}, + 'plugin:test/unselected/default': {'enable-all-tools': 'preserved'}, + }, + } + + assert RunnerConfigResolver.validate_agent_config(config) is config + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + @pytest.mark.parametrize('config_kind', ['agent', 'pipeline']) + def test_rejects_non_boolean_mcp_resource_enabled(self, config_kind, invalid_value): + runner_id = 'plugin:test/runner/default' + runner_config = {'mcp-resources': [{'uri': 'file:///README.md', 'enabled': invalid_value}]} + if config_kind == 'agent': + config = { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: runner_config}, + } + validate = RunnerConfigResolver.validate_agent_config + else: + config = { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: runner_config}, + } + } + validate = RunnerConfigResolver.validate_pipeline_config + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + validate(config) + + @pytest.mark.parametrize('enabled', [True, False]) + def test_accepts_boolean_mcp_resource_enabled(self, enabled): + runner_config = {'mcp-resources': [{'enabled': enabled}]} + + assert RunnerConfigResolver.validate_runner_security_fields(runner_config) is runner_config + + def test_get_expire_time_default(self): + expire_time = RunnerConfigResolver.get_expire_time({}) + assert expire_time == 0 diff --git a/tests/unit_tests/agent/test_config_migration_full.py b/tests/unit_tests/agent/test_config_resolver_templates.py similarity index 78% rename from tests/unit_tests/agent/test_config_migration_full.py rename to tests/unit_tests/agent/test_config_resolver_templates.py index 02c2e34b1..b8434b82b 100644 --- a/tests/unit_tests/agent/test_config_migration_full.py +++ b/tests/unit_tests/agent/test_config_resolver_templates.py @@ -1,10 +1,10 @@ -"""Tests for persisted AgentRunner config shape.""" +"""Tests for persisted AgentRunner config templates.""" from __future__ import annotations import json -from langbot.pkg.agent.runner.config_migration import ConfigMigration +from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver class TestDefaultPipelineConfig: @@ -35,7 +35,7 @@ class TestResolveRunnerId: 'runner': {'id': 'plugin:test/my-runner/default'}, }, } - runner_id = ConfigMigration.resolve_runner_id(config) + runner_id = RunnerConfigResolver.resolve_runner_id(config) assert runner_id == 'plugin:test/my-runner/default' def test_old_runner_field_is_not_supported(self): @@ -44,7 +44,7 @@ class TestResolveRunnerId: 'runner': {'runner': 'local-agent'}, }, } - runner_id = ConfigMigration.resolve_runner_id(config) + runner_id = RunnerConfigResolver.resolve_runner_id(config) assert runner_id is None @@ -59,7 +59,7 @@ class TestResolveRunnerConfig: }, }, } - runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') + runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') assert runner_config['custom-option'] == 20 def test_old_runner_block_is_not_supported(self): @@ -68,5 +68,5 @@ class TestResolveRunnerConfig: 'local-agent': {'custom-option': 20}, }, } - runner_config = ConfigMigration.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') + runner_config = RunnerConfigResolver.resolve_runner_config(config, 'plugin:langbot-team/LocalAgent/default') assert runner_config == {} diff --git a/tests/unit_tests/agent/test_execution_context.py b/tests/unit_tests/agent/test_execution_context.py new file mode 100644 index 000000000..512160f2b --- /dev/null +++ b/tests/unit_tests/agent/test_execution_context.py @@ -0,0 +1,225 @@ +"""Tests for Host-only AgentRunner tool execution context.""" + +from __future__ import annotations + +import json + +from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext +from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput +from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query +from langbot_plugin.api.entities.builtin.provider.message import ContentElement + +from langbot.pkg.agent.runner.execution_context import ( + append_mcp_resource_context_to_event, + build_execution_query, + build_host_box_scope, + prepare_box_scope, + prepare_execution_query, + project_mcp_resource_config, +) +from langbot.pkg.agent.runner.host_models import AgentEventEnvelope +from langbot.pkg.utils import constants + + +class PlatformAdapter: + pass + + +def make_event( + *, + event_id: str = 'event-1', + conversation_id: str | None = 'person_user-1', + target_type: str | None = 'person', + target_id: str | None = 'user-1', + adapter: str = 'PlatformAdapter', +) -> AgentEventEnvelope: + reply_target = {} + if target_type is not None: + reply_target['target_type'] = target_type + if target_id is not None: + reply_target['target_id'] = target_id + return AgentEventEnvelope( + event_id=event_id, + event_type='message.received', + source='platform', + bot_id='bot-1', + workspace_id='workspace-1', + conversation_id=conversation_id, + input=AgentInput(text='hello'), + delivery=DeliveryContext( + surface='platform', + reply_target=reply_target, + platform_capabilities={'adapter': adapter}, + ), + ) + + +def test_pipeline_and_event_execution_use_same_platform_session_scope(monkeypatch): + monkeypatch.setattr(constants, 'instance_id', 'instance-1') + event = make_event() + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + sender_id='user-1', + adapter=PlatformAdapter(), + variables={}, + ) + + prepare_execution_query(query, event, ['pdf']) + event_query = build_execution_query(event, ['pdf']) + + assert query.variables['_host_box_scope'] == event_query.variables['_host_box_scope'] + assert query.variables['_pipeline_bound_skills'] == ['pdf'] + assert event_query.variables['_pipeline_bound_skills'] == ['pdf'] + scope = json.loads(query.variables['_host_box_scope']) + assert scope == { + 'instance_id': 'instance-1', + 'workspace_id': 'workspace-1', + 'bot_id': 'bot-1', + 'platform_adapter': 'PlatformAdapter', + 'target_type': 'person', + 'target_id': 'user-1', + 'thread_id': None, + } + + +def test_prepare_box_scope_does_not_change_existing_skill_projection(): + event = make_event() + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + variables={'_pipeline_bound_skills': ['existing']}, + ) + + variables = prepare_box_scope(query, event) + + assert variables['_host_box_scope'] + assert variables['_pipeline_bound_skills'] == ['existing'] + + +def test_prepare_box_scope_preserves_event_first_channel_scope(): + channel_event = make_event(target_type='channel', target_id='same') + channel_query = build_execution_query(channel_event, []) + original_scope = channel_query.variables['_host_box_scope'] + + prepare_execution_query(channel_query, channel_event, []) + + person_scope = build_execution_query(make_event(target_type='person', target_id='same'), []).variables[ + '_host_box_scope' + ] + assert channel_query.variables['_host_box_scope'] == original_scope + assert json.loads(original_scope)['target_type'] == 'channel' + assert original_scope != person_scope + + +def test_prepare_box_scope_overwrites_untrusted_existing_scope(): + event = make_event(target_type='person', target_id='user-1') + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='user-1', + variables={'_host_box_scope': 'forged-scope'}, + ) + + variables = prepare_box_scope(query, event) + + assert variables['_host_box_scope'] != 'forged-scope' + assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1' + + +def test_project_mcp_resource_config_uses_independent_agent_runner_settings(): + query = pipeline_query.Query.model_construct(variables={}) + attachments = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + + variables = project_mcp_resource_config( + query, + { + 'mcp-resources': attachments, + 'mcp-resource-agent-read-enabled': False, + }, + ) + + assert variables['_pipeline_mcp_resource_attachments'] == attachments + assert variables['_pipeline_mcp_resource_attachments'] is not attachments + assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False + + +def test_project_mcp_resource_config_fails_closed_for_non_boolean_read_flag(): + query = pipeline_query.Query.model_construct(variables={}) + + variables = project_mcp_resource_config( + query, + {'mcp-resource-agent-read-enabled': 0}, + ) + + assert variables['_pipeline_mcp_resource_agent_read_enabled'] is False + + +def test_pinned_context_updates_text_and_structured_input(): + event = make_event() + event.input = AgentInput( + text='hello', + contents=[ContentElement.from_text('hello')], + ) + + append_mcp_resource_context_to_event(event, '\n\npinned-context') + + assert event.input.text == 'hello\n\npinned-context' + assert event.input.contents[0].text == 'hello\n\npinned-context' + + +def test_event_reply_target_populates_valid_session_identity(): + event = make_event(conversation_id='rotating-transcript-id', target_type='group', target_id='room-1') + + query = build_execution_query(event, []) + + assert query.launcher_type.value == 'group' + assert query.launcher_id == 'room-1' + assert query.sender_id == 'room-1' + assert query.session.launcher_type.value == 'group' + assert query.session.launcher_id == 'room-1' + scope = json.loads(query.variables['_host_box_scope']) + assert scope['target_type'] == 'group' + assert scope['target_id'] == 'room-1' + assert 'rotating-transcript-id' not in query.variables['_host_box_scope'] + + +def test_non_message_event_without_conversation_uses_event_scope(): + event = make_event( + event_id='scheduled/event/中文', + conversation_id=None, + target_type=None, + target_id=None, + adapter='scheduler', + ) + event.event_type = 'schedule.triggered' + + query = build_execution_query(event, []) + + scope = json.loads(query.variables['_host_box_scope']) + assert scope['target_type'] == 'event' + assert scope['target_id'] == event.event_id + assert query.pipeline_config is None + assert query.pipeline_uuid is None + + +def test_scope_isolated_by_instance_and_platform_adapter(monkeypatch): + event = make_event(adapter='AdapterA') + monkeypatch.setattr(constants, 'instance_id', 'instance-a') + first = build_host_box_scope(event) + + monkeypatch.setattr(constants, 'instance_id', 'instance-b') + second = build_host_box_scope(event) + other_adapter = build_host_box_scope(make_event(adapter='AdapterB')) + + assert first != second + assert second != other_adapter diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py index b353a4206..3926d1870 100644 --- a/tests/unit_tests/agent/test_handler_auth.py +++ b/tests/unit_tests/agent/test_handler_auth.py @@ -421,7 +421,7 @@ class TestRetrieveKnowledgeBaseAuthorization: # When no run_id, the handler checks against pipeline's configured KBs # This is the unscoped path for regular plugin calls - from langbot.pkg.agent.runner.config_migration import ConfigMigration + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver # Simulate pipeline config pipeline_config = { @@ -437,10 +437,10 @@ class TestRetrieveKnowledgeBaseAuthorization: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) assert runner_id == 'plugin:test/runner/default' - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) allowed_kbs = runner_config.get('knowledge-bases', []) assert 'kb_001' in allowed_kbs assert 'kb_999' not in allowed_kbs @@ -534,12 +534,12 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: without first resolving the runner_id, causing issues when non-local-agent runners were used. - Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config. + Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config. """ def test_retrieve_kb_fix_local_agent_runner(self): """Fix should work for local-agent runner.""" - from langbot.pkg.agent.runner.config_migration import ConfigMigration + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver pipeline_config = { 'ai': { @@ -554,15 +554,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) allowed_kbs = runner_config.get('knowledge-bases', []) assert 'kb_001' in allowed_kbs def test_retrieve_kb_fix_other_runner(self): """Fix should work for non-local-agent runners.""" - from langbot.pkg.agent.runner.config_migration import ConfigMigration + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver pipeline_config = { 'ai': { @@ -577,15 +577,15 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) allowed_kbs = runner_config.get('knowledge-bases', []) assert 'kb_custom' in allowed_kbs def test_retrieve_kb_does_not_read_old_runner_format(self): """The 4.x authorization path ignores legacy Pipeline runner fields.""" - from langbot.pkg.agent.runner.config_migration import ConfigMigration + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver pipeline_config = { 'ai': { @@ -598,8 +598,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) assert runner_id is None assert runner_config == {} @@ -922,7 +922,7 @@ class TestNoRunIdBackwardCompatPath: @pytest.mark.asyncio async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self): """RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check.""" - from langbot.pkg.agent.runner.config_migration import ConfigMigration + from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver # When no run_id, handler.py lines 897-914 check pipeline config pipeline_config = { @@ -938,8 +938,8 @@ class TestNoRunIdBackwardCompatPath: }, } - runner_id = ConfigMigration.resolve_runner_id(pipeline_config) - runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) + runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config) + runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) allowed_kb_uuids = runner_config.get('knowledge-bases', []) # kb_001 should be allowed diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index ec7f2824f..ba770a236 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -234,6 +234,12 @@ def make_query(): '_pipeline_bound_plugins': ['langbot-team/LocalAgent'], '_fallback_model_uuids': ['model_fallback'], '_pipeline_bound_skills': ['demo'], + '_host_tool_source_refs': { + 'langbot/test-tool/search': { + 'source': 'plugin', + 'source_id': 'langbot/test-tool', + }, + }, 'public_param': 'visible', }, use_llm_model_uuid='model_primary', @@ -704,6 +710,98 @@ async def test_orchestrator_unregisters_session_after_event_log_failure(clean_ag assert await get_session_registry().list_active_runs() == [] +@pytest.mark.asyncio +async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_agent_state): + """Dropped steering audits retain routing metadata but never execution-only context.""" + from langbot.pkg.agent.runner.event_log_store import EventLogStore + + class BlockingPluginConnector(FakePluginConnector): + def __init__(self): + super().__init__() + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def run_agent(self, plugin_author, plugin_name, runner_name, context): + self.calls.append( + { + 'plugin_author': plugin_author, + 'plugin_name': plugin_name, + 'runner_name': runner_name, + } + ) + self.contexts.append(context) + self.sessions_during_run.append(await get_session_registry().get(context['run_id'])) + self.started.set() + await self.release.wait() + yield { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + + db_engine = clean_agent_state + descriptor = make_descriptor() + descriptor.capabilities.steering = True + plugin_connector = BlockingPluginConnector() + ap = FakeApplication(plugin_connector, db_engine) + pinned_context = 'PINNED_CONTEXT_MUST_NOT_BE_PERSISTED' + + async def build_resource_context(query): + attachments = query.variables.get('_pipeline_mcp_resource_attachments', []) + return pinned_context if attachments else '' + + mcp_loader = types.SimpleNamespace(build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + + active_query = make_query() + + async def consume_active_run(): + return [message async for message in orchestrator.run_from_query(active_query)] + + active_task = asyncio.create_task(consume_active_run()) + await asyncio.wait_for(plugin_connector.started.wait(), timeout=1) + + steering_query = make_query() + steering_query.query_id = 1002 + steering_query.message_chain[0].id = 'msg_002' + steering_query.variables['_pipeline_mcp_resource_attachments'] = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + steering_query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True + + try: + claimed = await orchestrator.try_claim_steering_from_query(steering_query) + finally: + plugin_connector.release.set() + + messages = await asyncio.wait_for(active_task, timeout=1) + assert claimed is True + assert len(messages) == 1 + + event_store = EventLogStore(db_engine) + event_logs, _, _ = await event_store.page_events( + conversation_id=steering_query.session.using_conversation.uuid, + limit=10, + ) + dropped = next(event for event in event_logs if event['event_type'] == 'steering.dropped') + queued = next( + event + for event in event_logs + if event['event_type'] == 'message.received' + and event.get('metadata', {}).get('steering', {}).get('status') == 'queued' + ) + + assert dropped['input_summary'] == 'Unconsumed steering input dropped' + assert dropped['input_json'] is None + assert dropped['metadata']['steering']['original_event_id'] == queued['event_id'] + assert pinned_context not in str(event_logs) + + @pytest.mark.asyncio async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state): """Test that orchestrator enforces total runner timeout.""" @@ -733,6 +831,48 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state): class TestQueryEntrySessionQueryId: """Tests for internal query_id entering session registry.""" + @pytest.mark.asyncio + async def test_query_box_scope_exists_before_attachment_materialization(self, clean_agent_state): + """Inbound staging and later runner tools resolve to the same Box session.""" + from langbot.pkg.box.service import BoxService + + class CapturingBoxService: + available = True + + def __init__(self): + self.resolver = object.__new__(BoxService) + self.materialize_session_id = None + + async def materialize_inbound_attachments(self, query): + self.materialize_session_id = self.resolver.resolve_box_session_id(query) + return [] + + db_engine = clean_agent_state + descriptor = make_descriptor() + plugin_connector = FakePluginConnector( + results=[ + { + 'type': 'message.completed', + 'data': {'message': {'role': 'assistant', 'content': 'response'}}, + } + ] + ) + ap = FakeApplication(plugin_connector, db_engine) + box_service = CapturingBoxService() + ap.box_service = box_service + orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) + query = make_query() + + messages = [message async for message in orchestrator.run_from_query(query)] + + assert len(messages) == 1 + session = plugin_connector.sessions_during_run[0] + assert session is not None + assert session['execution_query'] is query + runner_session_id = box_service.resolver.resolve_box_session_id(session['execution_query']) + assert box_service.materialize_session_id == runner_session_id + assert query.variables['_host_box_scope'] + @pytest.mark.asyncio async def test_query_id_registered_in_session_for_query_entry_flow(self, clean_agent_state): """query_id from Query entry flow is registered internally in session.""" @@ -765,6 +905,9 @@ class TestQueryEntrySessionQueryId: session_during_run = plugin_connector.sessions_during_run[0] assert session_during_run is not None assert session_during_run['query_id'] == query.query_id + assert session_during_run['execution_query'] is query + assert query.pipeline_uuid == 'pipeline_001' + assert query.pipeline_config is not None @pytest.mark.asyncio async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state): @@ -791,6 +934,10 @@ class TestQueryEntrySessionQueryId: ] ) ap = FakeApplication(plugin_connector, db_engine) + mcp_loader = types.SimpleNamespace( + build_resource_context_for_query=AsyncMock(return_value='Pinned documentation') + ) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) # Create event and binding directly (not from Query) @@ -805,7 +952,11 @@ class TestQueryEntrySessionQueryId: thread_id=None, actor=None, subject=None, - input=AgentInput(text='hello', contents=[], attachments=[]), + input=AgentInput( + text='hello', + contents=[provider_message.ContentElement.from_text('hello')], + attachments=[], + ), delivery=DeliveryContext(surface='test', supports_streaming=True), ) binding = AgentBinding( @@ -813,7 +964,17 @@ class TestQueryEntrySessionQueryId: scope=BindingScope(scope_type='agent', scope_id='pipeline_001'), event_types=['message.received'], runner_id=RUNNER_ID, - runner_config={}, + runner_config={ + 'mcp-resources': [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ], + 'mcp-resource-agent-read-enabled': True, + }, resource_policy=ResourcePolicy(), state_policy=StatePolicy(enable_state=False, state_scopes=[]), delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True), @@ -827,6 +988,26 @@ class TestQueryEntrySessionQueryId: session_during_run = plugin_connector.sessions_during_run[0] assert session_during_run is not None assert session_during_run['query_id'] is None + execution_query = session_during_run['execution_query'] + assert execution_query is not None + assert execution_query.pipeline_uuid is None + assert execution_query.pipeline_config is None + assert execution_query.bot_uuid == event.bot_id + assert execution_query.launcher_id == event.conversation_id + assert execution_query.sender_id == event.conversation_id + assert execution_query.session.launcher_id == event.conversation_id + assert execution_query.message_event.type == event.event_type + assert execution_query.variables['_host_box_scope'] + assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden'] + assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1' + assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True + assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text'] + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text'] + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text'] + assert event.input.text == 'hello' + assert event.input.contents[0].text == 'hello' + assert 'Pinned documentation' not in str(execution_query.user_message.content) + mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query) class TestQueryEntryAdapterParams: @@ -1106,8 +1287,21 @@ class TestQueryEntryAdapterHostCapabilities: ] ) ap = FakeApplication(plugin_connector, db_engine) + mcp_loader = types.SimpleNamespace( + build_resource_context_for_query=AsyncMock(return_value='Pinned documentation') + ) + ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader) orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) query = make_query() + query.variables['_pipeline_mcp_resource_attachments'] = [ + { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + } + ] + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = True query.user_message = provider_message.Message( role='user', content=[ @@ -1119,6 +1313,7 @@ class TestQueryEntryAdapterHostCapabilities: messages = [message async for message in orchestrator.run_from_query(query)] assert len(messages) == 1 + assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['text'] # Check EventLog has incoming event event_log_store = EventLogStore(db_engine) @@ -1132,6 +1327,7 @@ class TestQueryEntryAdapterHostCapabilities: assert event_logs[0]['input_json']['contents'][1]['image_base64'] is None assert event_logs[0]['input_json']['contents'][1]['content_redacted'] is True assert 'aGVsbG8=' not in str(event_logs[0]['input_json']) + assert 'Pinned documentation' not in str(event_logs[0]['input_json']) # Check Transcript has user and assistant messages transcript_store = TranscriptStore(db_engine) @@ -1149,3 +1345,4 @@ class TestQueryEntryAdapterHostCapabilities: assert user_item['content_json']['content'][1]['image_base64'] is None assert user_item['attachment_refs'][0]['content'] is None assert 'aGVsbG8=' not in str(user_item) + assert 'Pinned documentation' not in str(user_item) diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index ed642fa4c..7f41fc705 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -1,4 +1,5 @@ """Tests for AgentResourceBuilder.""" + from __future__ import annotations from types import SimpleNamespace @@ -10,6 +11,7 @@ from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder +from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy RUNNER_ID = 'plugin:test/runner/default' @@ -100,6 +102,7 @@ def app(): mock_app.skill_mgr = None mock_app.tool_mgr = Mock() mock_app.tool_mgr.get_tool_schema = AsyncMock(return_value=(None, None)) + mock_app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) return mock_app @@ -130,11 +133,13 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app {'name': 'rerank-model', 'type': 'rerank-model-selector'}, ], ) - query = make_query({ - 'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']}, - 'aux-model': 'aux', - 'rerank-model': 'rerank', - }) + query = make_query( + { + 'model': {'primary': 'primary', 'fallbacks': ['fallback', 'primary']}, + 'aux-model': 'aux', + 'rerank-model': 'rerank', + } + ) resources = await build_resources(app, query, descriptor) @@ -173,10 +178,12 @@ async def test_build_models_from_config_without_manifest_acl(app): ], permissions={}, ) - query = make_query({ - 'model': {'primary': 'primary', 'fallbacks': ['fallback']}, - 'rerank-model': 'rerank', - }) + query = make_query( + { + 'model': {'primary': 'primary', 'fallbacks': ['fallback']}, + 'rerank-model': 'rerank', + } + ) resources = await build_resources(app, query, descriptor) @@ -196,10 +203,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app): {'name': 'rerank-model', 'type': 'rerank-model-selector'}, ], ) - query = make_query({ - 'model': 'llm', - 'rerank-model': 'rerank', - }) + query = make_query( + { + 'model': 'llm', + 'rerank-model': 'rerank', + } + ) resources = await build_resources(app, query, descriptor) @@ -234,10 +243,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app): {'name': 'knowledge-bases', 'type': 'select-knowledge-bases'}, ], ) - query = make_query({ - 'model': 'llm_alias', - 'knowledge-bases': ['kb_alias'], - }) + query = make_query( + { + 'model': 'llm_alias', + 'knowledge-bases': ['kb_alias'], + } + ) resources = await build_resources(app, query, descriptor) @@ -271,10 +282,12 @@ async def test_build_models_manifest_permission_narrows_binding(app): 'models': ['rerank'], }, ) - query = make_query({ - 'model': 'llm', - 'rerank-model': 'rerank', - }) + query = make_query( + { + 'model': 'llm', + 'rerank-model': 'rerank', + } + ) resources = await build_resources(app, query, descriptor) @@ -309,7 +322,7 @@ async def test_build_tools_authorizes_query_declared_tools(app): """Tools discovered by Pipeline preprocessing become run-scoped authorized resources, with full parameters schema prefilled by the host.""" app.tool_mgr.get_tool_schema = AsyncMock( - side_effect=lambda name: { + side_effect=lambda name, source_ref=None: { 'qa_plugin_echo': ( 'Echo test tool', {'type': 'object', 'properties': {'text': {'type': 'string'}}}, @@ -321,6 +334,12 @@ async def test_build_tools_authorizes_query_declared_tools(app): ) query = make_query( {}, + variables={ + '_host_tool_source_refs': { + 'qa_plugin_echo': {'source': 'plugin', 'source_id': 'test/plugin'}, + 'qa_mcp_echo': {'source': 'mcp', 'source_id': 'mcp-server'}, + }, + }, use_funcs=[ {'name': 'qa_plugin_echo', 'description': 'Echo test tool'}, SimpleNamespace(name='qa_mcp_echo'), @@ -332,17 +351,21 @@ async def test_build_tools_authorizes_query_declared_tools(app): assert resources['tools'] == [ { 'tool_name': 'qa_plugin_echo', - 'tool_type': None, + 'tool_type': 'plugin', 'description': 'Echo test tool', 'operations': ['detail', 'call'], 'parameters': {'type': 'object', 'properties': {'text': {'type': 'string'}}}, + 'source': 'plugin', + 'source_id': 'test/plugin', }, { 'tool_name': 'qa_mcp_echo', - 'tool_type': None, + 'tool_type': 'mcp', 'description': None, 'operations': ['detail', 'call'], 'parameters': None, + 'source': 'mcp', + 'source_id': 'mcp-server', }, ] @@ -369,6 +392,103 @@ async def test_build_tools_manifest_permission_denies_binding_tools(app): assert resources['tools'] == [] +@pytest.mark.asyncio +async def test_build_tools_materializes_independent_agent_all_tools_policy(app): + """Independent Agents resolve an all-tools grant against the live Host catalog.""" + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + {'name': 'exec', 'source': 'builtin'}, + {'name': 'plugin_tool', 'source': 'plugin', 'source_id': 'test/plugin'}, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert [tool['tool_name'] for tool in resources['tools']] == ['exec', 'plugin_tool'] + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_build_tools_denies_mcp_resource_tools_when_agent_reads_disabled(app): + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + {'name': 'exec', 'source': 'builtin'}, + {'name': 'langbot_mcp_list_resources', 'source': 'mcp'}, + {'name': 'langbot_mcp_read_resource', 'source': 'mcp'}, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={'mcp-resource-agent-read-enabled': False}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert [tool['tool_name'] for tool in resources['tools']] == ['exec'] + + +@pytest.mark.asyncio +async def test_build_tools_keeps_plugin_using_synthetic_mcp_tool_name_when_reads_disabled(app): + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( + return_value=[ + { + 'name': 'langbot_mcp_read_resource', + 'source': 'plugin', + 'source_id': 'test/resource-reader', + }, + ] + ) + descriptor = make_descriptor(capabilities={'tool_calling': True}) + binding = AgentBinding( + binding_id='agent-binding', + scope=BindingScope(scope_type='agent', scope_id='agent-1'), + runner_id=RUNNER_ID, + runner_config={'mcp-resource-agent-read-enabled': False}, + resource_policy=ResourcePolicy(allow_all_tools=True), + ) + + resources = await AgentResourceBuilder(app).build_resources_from_binding( + event=QueryEntryAdapter.query_to_event(make_query({})), + binding=binding, + descriptor=descriptor, + ) + + assert resources['tools'] == [ + { + 'tool_name': 'langbot_mcp_read_resource', + 'tool_type': 'plugin', + 'description': None, + 'operations': ['detail', 'call'], + 'parameters': None, + 'source': 'plugin', + 'source_id': 'test/resource-reader', + } + ] + + @pytest.mark.asyncio async def test_build_knowledge_bases_unions_config_and_policy_grants(app): descriptor = make_descriptor( diff --git a/tests/unit_tests/agent/test_resource_policy.py b/tests/unit_tests/agent/test_resource_policy.py new file mode 100644 index 000000000..839d62fb2 --- /dev/null +++ b/tests/unit_tests/agent/test_resource_policy.py @@ -0,0 +1,91 @@ +"""Tests for generic AgentRunner resource-policy projection.""" + +from types import SimpleNamespace + +import pytest + +from langbot.pkg.agent.runner.resource_policy import ResourcePolicyProjector + + +def test_pipeline_projection_intersects_selected_tools_with_scoped_tools(): + policy = ResourcePolicyProjector.from_runner_config( + { + 'enable-all-tools': False, + 'tools': ['plugin_tool', 'missing_tool', 'plugin_tool'], + 'knowledge-bases': ['kb-config'], + }, + resolved_model_uuids=['model-1', 'model-1'], + resolved_tool_names=['exec', 'plugin_tool', 'mcp_tool'], + resolved_kb_uuids=['kb-runtime'], + resolved_skill_names=[], + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['plugin_tool'] + assert policy.allowed_model_uuids == ['model-1'] + assert policy.allowed_kb_uuids == ['kb-runtime'] + assert policy.allowed_skill_names == [] + + +def test_pipeline_projection_materializes_enable_all_tools_from_scoped_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': True, 'tools': ['ignored']}, + resolved_tool_names=['exec', 'mcp_tool'], + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['exec', 'mcp_tool'] + + +def test_pipeline_projection_keeps_sources_only_for_authorized_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['mcp_tool']}, + resolved_tool_names=['plugin_tool', 'mcp_tool'], + resolved_tool_sources={ + 'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'}, + 'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'}, + }, + ) + + assert policy.allowed_tool_sources == { + 'mcp_tool': {'source': 'mcp', 'source_id': 'mcp-server'}, + } + + +def test_independent_agent_projection_preserves_all_tools_intent(): + policy = ResourcePolicyProjector.from_runner_config({}) + + assert policy.allow_all_tools is True + assert policy.allowed_tool_names is None + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_enable_all_tools_fails_closed_for_non_boolean_values(invalid_value): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': invalid_value, 'tools': ['explicit-tool']}, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['explicit-tool'] + + +def test_independent_agent_projection_preserves_selected_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['exec', None, 'exec', 123]}, + ) + + assert policy.allow_all_tools is False + assert policy.allowed_tool_names == ['exec'] + + +def test_filter_tools_supports_sdk_objects_and_dictionary_tools(): + policy = ResourcePolicyProjector.from_runner_config( + {'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']}, + ) + tools = [ + {'name': 'dict-tool'}, + SimpleNamespace(name='object-tool'), + SimpleNamespace(name='other-tool'), + ] + + assert ResourcePolicyProjector.filter_tools(tools, policy) == tools[:2] diff --git a/tests/unit_tests/agent/test_session_registry.py b/tests/unit_tests/agent/test_session_registry.py index 3c2f05ef4..6b476f56b 100644 --- a/tests/unit_tests/agent/test_session_registry.py +++ b/tests/unit_tests/agent/test_session_registry.py @@ -1,4 +1,5 @@ """Tests for AgentRunSessionRegistry.""" + from __future__ import annotations import pytest @@ -49,6 +50,27 @@ class TestSessionRegistryBasic: assert 'permissions' not in result assert '_authorized_ids' not in result + @pytest.mark.asyncio + async def test_register_keeps_host_execution_query_by_identity(self): + """The runtime registry keeps the Host Query view in memory only.""" + registry = AgentRunSessionRegistry() + execution_query = object() + + await registry.register( + run_id='run_execution_query', + runner_id='plugin:test/my-runner/default', + query_id=None, + plugin_identity='test/my-runner', + resources=make_resources(), + execution_query=execution_query, + ) + + session = await registry.get('run_execution_query') + + assert session is not None + assert session['query_id'] is None + assert session['execution_query'] is execution_query + @pytest.mark.asyncio async def test_register_requires_plugin_identity(self): """Agent run sessions must always have an owning plugin identity.""" @@ -319,27 +341,36 @@ class TestSessionRegistryBasic: available_apis={'steering_pull': True}, ) - assert await registry.find_steering_target( - conversation_id='conv_1', - runner_id='plugin:test/my-runner/default', - bot_id='bot_1', - workspace_id='workspace_1', - thread_id='thread_1', - ) == 'run_steering_scoped' - assert await registry.find_steering_target( - conversation_id='conv_1', - runner_id='plugin:test/my-runner/default', - bot_id='bot_2', - workspace_id='workspace_1', - thread_id='thread_1', - ) is None - assert await registry.find_steering_target( - conversation_id='conv_1', - runner_id='plugin:test/my-runner/default', - bot_id='bot_1', - workspace_id='workspace_1', - thread_id='thread_2', - ) is None + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_1', + ) + == 'run_steering_scoped' + ) + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_2', + workspace_id='workspace_1', + thread_id='thread_1', + ) + is None + ) + assert ( + await registry.find_steering_target( + conversation_id='conv_1', + runner_id='plugin:test/my-runner/default', + bot_id='bot_1', + workspace_id='workspace_1', + thread_id='thread_2', + ) + is None + ) @pytest.mark.asyncio async def test_unregister_returns_pending_steering_queue(self): @@ -554,6 +585,7 @@ class TestGlobalRegistry: # in tests can cause UnboundLocalError due to Python scoping # Instead, just verify the function signature from langbot.pkg.agent.runner.session_registry import get_session_registry + assert callable(get_session_registry) # Create a fresh instance directly to verify the class works diff --git a/tests/unit_tests/api/service/test_agent_service.py b/tests/unit_tests/api/service/test_agent_service.py index 7a57938fb..0b66df6ea 100644 --- a/tests/unit_tests/api/service/test_agent_service.py +++ b/tests/unit_tests/api/service/test_agent_service.py @@ -39,7 +39,8 @@ def _agent_row( emoji='A', kind=AGENT_KIND_AGENT, component_ref='plugin:test/runner/default', - config=config or { + config=config + or { 'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0}, 'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}}, }, @@ -71,11 +72,7 @@ def _compiled_params(statement): def _compiled_update_values(statement): - return { - key: value - for key, value in statement.compile().params.items() - if not key.startswith('uuid_') - } + return {key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')} def _make_app(): @@ -224,6 +221,7 @@ class TestAgentServiceCreateUpdateDelete: 'name': 'Support Agent', 'description': 'Handles support events', 'emoji': 'S', + 'component_ref': 'plugin:caller/must-not-win/default', } ) @@ -240,6 +238,122 @@ class TestAgentServiceCreateUpdateDelete: assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema) + @pytest.mark.parametrize( + 'config', + [ + None, + [], + {'runner': {'id': 'plugin:test/runner/default'}}, + {'runner': {'id': 123}, 'runner_config': {}}, + { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': ['invalid']}, + }, + { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {}, + }, + ], + ) + async def test_create_agent_rejects_malformed_4x_runner_config(self, config): + app = _make_app() + + with pytest.raises(ValueError, match='Agent config|runner_config'): + await AgentService(app).create_agent({'name': 'Invalid Agent', 'config': config}) + + app.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + ('enable-all-tools', 0), + ('enable-all-tools', None), + ('enable-all-tools', 'false'), + ('mcp-resource-agent-read-enabled', 0), + ('mcp-resource-agent-read-enabled', None), + ('mcp-resource-agent-read-enabled', 'false'), + ], + ) + async def test_create_agent_rejects_non_boolean_security_fields_before_write( + self, + field_name, + invalid_value, + ): + app = _make_app() + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=f'{field_name}.*boolean'): + await AgentService(app).create_agent( + { + 'name': 'Invalid Agent', + 'config': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {field_name: invalid_value}}, + }, + } + ) + + app.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_agent_rejects_non_boolean_mcp_resource_enabled_before_write(self, invalid_value): + app = _make_app() + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + await AgentService(app).create_agent( + { + 'name': 'Invalid Agent', + 'config': { + 'runner': {'id': runner_id}, + 'runner_config': { + runner_id: { + 'mcp-resources': [ + {'uri': 'file:///README.md', 'enabled': invalid_value}, + ] + } + }, + }, + } + ) + + app.persistence_mgr.execute_async.assert_not_awaited() + + async def test_create_agent_derives_empty_component_ref_from_empty_runner(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=Mock()) + + await AgentService(app).create_agent( + { + 'name': 'Unconfigured Agent', + 'component_ref': 'plugin:caller/must-not-win/default', + 'config': { + 'runner': {'id': ''}, + 'runner_config': {}, + }, + } + ) + + insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) + assert insert_values['component_ref'] is None + + async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1'))) + + with pytest.raises(ValueError, match='runner_config'): + await AgentService(app).update_agent( + 'agent-1', + { + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': 'invalid'}, + } + }, + ) + + assert app.persistence_mgr.execute_async.await_count == 1 + async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self): app = _make_app() app.persistence_mgr.execute_async = AsyncMock( @@ -261,6 +375,7 @@ class TestAgentServiceCreateUpdateDelete: 'created_at': '2020-01-01T00:00:00', 'updated_at': '2020-01-01T00:00:00', 'capability': {'message_only': True}, + 'component_ref': 'plugin:caller/must-not-win/default', 'name': 'Updated Agent', 'config': new_config, 'supported_event_patterns': [], @@ -275,6 +390,67 @@ class TestAgentServiceCreateUpdateDelete: 'component_ref': 'plugin:test/new-runner/default', } + async def test_update_agent_ignores_component_ref_without_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + + await AgentService(app).update_agent( + 'agent-1', + { + 'name': 'Updated Agent', + 'component_ref': 'plugin:caller/must-not-win/default', + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == { + 'name': 'Updated Agent', + 'component_ref': 'plugin:test/runner/default', + } + + async def test_update_agent_component_ref_only_repairs_from_existing_config(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + + await AgentService(app).update_agent( + 'agent-1', + {'component_ref': 'plugin:caller/must-not-win/default'}, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == {'component_ref': 'plugin:test/runner/default'} + + async def test_update_agent_clears_component_ref_for_empty_runner(self): + app = _make_app() + app.persistence_mgr.execute_async = AsyncMock( + side_effect=[ + _result(first_item=_agent_row(agent_uuid='agent-1')), + Mock(), + ] + ) + config = {'runner': {'id': ''}, 'runner_config': {}} + + await AgentService(app).update_agent( + 'agent-1', + { + 'component_ref': 'plugin:caller/must-not-win/default', + 'config': config, + }, + ) + + update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0]) + assert update_values == {'config': config, 'component_ref': None} + async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self): app = _make_app() app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None)) diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index 11676b354..6ce935736 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -231,6 +231,63 @@ class TestPipelineServiceCreatePipeline: with pytest.raises(ValueError, match='Maximum number of pipelines'): await service.create_pipeline({'name': 'New Pipeline'}) + @pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) + async def test_create_pipeline_rejects_non_object_extension_preferences( + self, + invalid_preferences, + ): + service = PipelineService(SimpleNamespace()) + + with pytest.raises(ValueError, match='extensions_preferences must be an object'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'extensions_preferences': invalid_preferences, + } + ) + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_pipeline_rejects_non_boolean_runner_security_field(self, invalid_value): + service = PipelineService(SimpleNamespace()) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match='enable-all-tools.*boolean'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {'enable-all-tools': invalid_value}}, + } + }, + } + ) + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_create_pipeline_rejects_non_boolean_mcp_resource_enabled(self, invalid_value): + service = PipelineService(SimpleNamespace()) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match=r'mcp-resources\[0\]\.enabled.*boolean'): + await service.create_pipeline( + { + 'name': 'Invalid Pipeline', + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': { + runner_id: { + 'mcp-resources': [ + {'uri': 'file:///README.md', 'enabled': invalid_value}, + ] + } + }, + } + }, + } + ) + async def test_create_pipeline_no_limit(self): """Creates pipeline without limit when max_pipelines=-1.""" # Setup @@ -387,6 +444,43 @@ class TestPipelineServiceUpdatePipeline: assert ['should-be-removed'] not in update_params.values() assert not any(value is True for value in update_params.values()) + @pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) + async def test_update_pipeline_rejects_non_object_extension_preferences_before_write( + self, + invalid_preferences, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match='extensions_preferences must be an object'): + await service.update_pipeline( + 'test-uuid', + {'extensions_preferences': invalid_preferences}, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_pipeline_rejects_non_boolean_runner_security_field_before_write(self, invalid_value): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + runner_id = 'plugin:test/runner/default' + + with pytest.raises(ValueError, match='mcp-resource-agent-read-enabled.*boolean'): + await service.update_pipeline( + 'test-uuid', + { + 'config': { + 'ai': { + 'runner': {'id': runner_id}, + 'runner_config': {runner_id: {'mcp-resource-agent-read-enabled': invalid_value}}, + } + } + }, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + async def test_update_pipeline_name_does_not_rewrite_bot_routes(self): """Bot event bindings remain independent from pipeline display names.""" # Setup @@ -624,6 +718,98 @@ class TestPipelineServiceUpdatePipelineExtensions: with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'): await service.update_pipeline_extensions('nonexistent-uuid', []) + @pytest.mark.parametrize( + ('field', 'invalid_value'), + [ + ('bound_plugins', 'author/plugin'), + ('bound_plugins', [{'author': 'author'}]), + ('bound_mcp_servers', 'server-1'), + ('bound_mcp_servers', ['server-1', 2]), + ('bound_skills', 'skill-1'), + ('bound_skills', ['skill-1', None]), + ('bound_mcp_resources', {'uri': 'file:///README.md'}), + ('bound_mcp_resources', [{'uri': 'file:///README.md'}, 'bad']), + ], + ) + async def test_update_extensions_rejects_malformed_binding_lists_before_query( + self, + field, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + kwargs = {field: invalid_value} + if field != 'bound_plugins': + kwargs['bound_plugins'] = [] + + with pytest.raises(ValueError, match=field): + await service.update_pipeline_extensions('test-uuid', **kwargs) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, 'false']) + async def test_update_extensions_rejects_non_boolean_resource_read_before_query(self, invalid_value): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match='mcp_resource_agent_read_enabled.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + mcp_resource_agent_read_enabled=invalid_value, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize( + 'field', + [ + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + ], + ) + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_extensions_rejects_non_boolean_enable_all_flags_before_query( + self, + field, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match=rf'{field}.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + **{field: invalid_value}, + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + async def test_update_extensions_rejects_non_boolean_attachment_enabled_before_query( + self, + invalid_value, + ): + ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock())) + service = PipelineService(ap) + + with pytest.raises(ValueError, match=r'bound_mcp_resources.*enabled.*boolean'): + await service.update_pipeline_extensions( + 'test-uuid', + [], + bound_mcp_resources=[ + { + 'server_uuid': 'server-1', + 'uri': 'file:///README.md', + 'enabled': invalid_value, + } + ], + ) + + ap.persistence_mgr.execute_async.assert_not_awaited() + async def test_update_extensions_sets_plugins(self): """Updates plugins in extensions_preferences.""" # Setup @@ -667,7 +853,7 @@ class TestPipelineServiceUpdatePipelineExtensions: ) # Execute - bound_plugins = [{'plugin_uuid': 'plugin-1'}] + bound_plugins = [{'author': 'test', 'name': 'plugin-1'}] await service.update_pipeline_extensions( 'test-uuid', bound_plugins=bound_plugins, diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py new file mode 100644 index 000000000..1a1fe5a05 --- /dev/null +++ b/tests/unit_tests/api/test_agents_controller.py @@ -0,0 +1,67 @@ +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(agent_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(agent_service=agent_service, user_service=user_service) + AgentsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.agents').AgentsRouterGroup + group = AgentsRouterGroup(ap, app) + await group.initialize() + return app.test_client() + + +async def test_create_agent_returns_bad_request_for_invalid_runner_config(): + message = 'agent config runner_config must be an object' + agent_service = SimpleNamespace(create_agent=AsyncMock(side_effect=ValueError(message))) + client = await _create_test_client(agent_service) + + response = await client.post( + '/api/v1/agents', + json={'name': 'Invalid Agent', 'config': {'runner_config': []}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + agent_service.create_agent.assert_awaited_once_with( + {'name': 'Invalid Agent', 'config': {'runner_config': []}}, + ) + + +async def test_update_agent_returns_bad_request_for_invalid_runner_config(): + message = 'agent config runner.id must be a string' + agent_service = SimpleNamespace(update_agent=AsyncMock(side_effect=ValueError(message))) + client = await _create_test_client(agent_service) + + response = await client.put( + '/api/v1/agents/agent-1', + json={'config': {'runner': {'id': 7}}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + agent_service.update_agent.assert_awaited_once_with( + 'agent-1', + {'config': {'runner': {'id': 7}}}, + ) diff --git a/tests/unit_tests/api/test_pipelines_controller.py b/tests/unit_tests/api/test_pipelines_controller.py new file mode 100644 index 000000000..66300b7fa --- /dev/null +++ b/tests/unit_tests/api/test_pipelines_controller.py @@ -0,0 +1,213 @@ +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(pipeline_service: SimpleNamespace, **extra_ap): + 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(pipeline_service=pipeline_service, user_service=user_service, **extra_ap) + router_class = import_module('langbot.pkg.api.http.controller.groups.pipelines.pipelines').PipelinesRouterGroup + group = router_class(ap, app) + await group.initialize() + return app.test_client() + + +@pytest.mark.parametrize( + ('method', 'path', 'service_method'), + [ + ('post', '/api/v1/pipelines', 'create_pipeline'), + ('put', '/api/v1/pipelines/pipeline-1', 'update_pipeline'), + ], +) +async def test_pipeline_writes_return_bad_request_for_invalid_runner_security_field( + method, + path, + service_method, +): + message = "Pipeline runner_config['runner-1'] field 'enable-all-tools' must be a boolean" + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + getattr(pipeline_service, service_method).side_effect = ValueError(message) + client = await _create_test_client(pipeline_service) + + response = await getattr(client, method)( + path, + json={'config': {'ai': {'runner': {'id': 'runner-1'}}}}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_reject_non_boolean_resource_read(invalid_value): + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={'mcp_resource_agent_read_enabled': invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == { + 'code': -1, + 'msg': "Pipeline extension field 'mcp_resource_agent_read_enabled' must be a boolean", + } + pipeline_service.update_pipeline_extensions.assert_not_awaited() + + +@pytest.mark.parametrize( + 'field', + [ + 'enable_all_plugins', + 'enable_all_mcp_servers', + 'enable_all_skills', + ], +) +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_reject_non_boolean_enable_all_flags(field, invalid_value): + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={field: invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == { + 'code': -1, + 'msg': f"Pipeline extension field '{field}' must be a boolean", + } + pipeline_service.update_pipeline_extensions.assert_not_awaited() + + +@pytest.mark.parametrize( + ('field', 'invalid_value'), + [ + ('bound_plugins', 'author/plugin'), + ('bound_mcp_servers', 'server-1'), + ('bound_skills', 'skill-1'), + ('bound_mcp_resources', {'uri': 'file:///README.md'}), + ], +) +async def test_pipeline_extensions_return_bad_request_for_malformed_binding_lists( + field, + invalid_value, +): + message = f"Pipeline extension field '{field}' must be a list" + pipeline_service = SimpleNamespace( + create_pipeline=AsyncMock(), + update_pipeline=AsyncMock(), + update_pipeline_extensions=AsyncMock(side_effect=ValueError(message)), + ) + client = await _create_test_client(pipeline_service) + + response = await client.put( + '/api/v1/pipelines/pipeline-1/extensions', + json={field: invalid_value}, + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 400 + assert await response.get_json() == {'code': -1, 'msg': message} + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_extensions_get_normalizes_malformed_enable_all_flags(invalid_value): + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': invalid_value, + 'enable_all_mcp_servers': invalid_value, + 'enable_all_skills': invalid_value, + 'mcp_resource_agent_read_enabled': invalid_value, + } + } + ), + ) + client = await _create_test_client( + pipeline_service, + plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), + mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), + skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), + logger=SimpleNamespace(warning=AsyncMock()), + ) + + response = await client.get( + '/api/v1/pipelines/pipeline-1/extensions', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['enable_all_plugins'] is False + assert payload['data']['enable_all_mcp_servers'] is False + assert payload['data']['enable_all_skills'] is False + assert payload['data']['mcp_resource_agent_read_enabled'] is False + + +@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) +async def test_pipeline_extensions_get_malformed_root_is_fail_closed(invalid_preferences): + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={'extensions_preferences': invalid_preferences} + ), + ) + client = await _create_test_client( + pipeline_service, + plugin_connector=SimpleNamespace(list_plugins=AsyncMock(return_value=[])), + mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])), + skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])), + logger=SimpleNamespace(warning=AsyncMock()), + ) + + response = await client.get( + '/api/v1/pipelines/pipeline-1/extensions', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['enable_all_plugins'] is False + assert payload['data']['enable_all_mcp_servers'] is False + assert payload['data']['enable_all_skills'] is False + assert payload['data']['mcp_resource_agent_read_enabled'] is False + assert payload['data']['bound_plugins'] == [] + assert payload['data']['bound_mcp_servers'] == [] + assert payload['data']['bound_skills'] == [] + assert payload['data']['bound_mcp_resources'] == [] diff --git a/tests/unit_tests/api/test_tools_controller.py b/tests/unit_tests/api/test_tools_controller.py new file mode 100644 index 000000000..bf40239bc --- /dev/null +++ b/tests/unit_tests/api/test_tools_controller.py @@ -0,0 +1,301 @@ +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(tool_mgr: SimpleNamespace, pipeline_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( + tool_mgr=tool_mgr, + pipeline_service=pipeline_service, + user_service=user_service, + ) + router_class = import_module('langbot.pkg.api.http.controller.groups.resources.tools').ToolsRouterGroup + group = router_class(ap, app) + await group.initialize() + return app.test_client() + + +async def test_global_tool_selector_uses_unambiguous_host_catalog(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'unique_tool', 'source': 'builtin'}]) + ) + pipeline_service = SimpleNamespace(get_pipeline=AsyncMock()) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tools'] == [{'name': 'unique_tool', 'source': 'builtin'}] + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + ) + pipeline_service.get_pipeline.assert_not_awaited() + + +async def test_pipeline_tool_selector_resolves_only_bound_sources(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'shared_tool', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ) + ) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': False, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': False, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tools'][0]['source_id'] == 'bound-mcp' + pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1') + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false']) +async def test_pipeline_tool_selector_malformed_enable_all_flags_fail_closed(invalid_value): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': invalid_value, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': invalid_value, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('invalid_preferences', [None, [], 'all', 0, False]) +async def test_pipeline_tool_selector_malformed_extension_root_uses_empty_allowlists( + invalid_preferences, +): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={'extensions_preferences': invalid_preferences} + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + [], + [], + include_skill_authoring=True, + ) + + +async def test_pipeline_tool_selector_malformed_binding_lists_use_empty_allowlists(): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': True, + 'plugins': 'allowed/plugin', + 'enable_all_mcp_servers': True, + 'mcp_servers': 'bound-mcp', + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools?pipeline_id=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + [], + [], + include_skill_authoring=True, + ) + + +@pytest.mark.parametrize('pipeline_query_key', ['pipeline_uuid', 'pipeline_id']) +async def test_tool_detail_uses_pipeline_scoped_catalog_and_path_tool_name(pipeline_query_key): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'namespace/unique_tool', + 'description': 'Unique tool', + 'human_desc': 'A unique tool', + 'parameters': {'type': 'object'}, + 'source': 'plugin', + 'source_name': 'allowed/plugin', + 'source_id': 'allowed/plugin', + } + ] + ) + ) + pipeline_service = SimpleNamespace( + get_pipeline=AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_plugins': False, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': False, + 'mcp_servers': ['bound-mcp'], + } + } + ) + ) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + f'/api/v1/tools/namespace%2Funique_tool?{pipeline_query_key}=pipeline-1', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tool'] == { + 'name': 'namespace/unique_tool', + 'description': 'Unique tool', + 'human_desc': 'A unique tool', + 'parameters': {'type': 'object'}, + 'source': 'plugin', + 'source_name': 'allowed/plugin', + 'source_id': 'allowed/plugin', + } + pipeline_service.get_pipeline.assert_awaited_once_with('pipeline-1') + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + ['allowed/plugin'], + ['bound-mcp'], + include_skill_authoring=True, + ) + + +async def test_global_builtin_tool_detail_includes_nullable_source_id(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock( + return_value=[ + { + 'name': 'exec', + 'description': 'Execute a command', + 'human_desc': 'Execute', + 'parameters': {'type': 'object'}, + 'source': 'builtin', + 'source_name': 'LangBot', + } + ] + ) + ) + client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock())) + + response = await client.get( + '/api/v1/tools/exec', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data']['tool']['source'] == 'builtin' + assert payload['data']['tool']['source_id'] is None + + +async def test_tool_detail_hides_ambiguous_or_missing_name(): + tool_mgr = SimpleNamespace( + get_resolved_tool_catalog=AsyncMock(return_value=[{'name': 'other_tool', 'source': 'builtin'}]) + ) + client = await _create_test_client(tool_mgr, SimpleNamespace(get_pipeline=AsyncMock())) + + response = await client.get( + '/api/v1/tools/shared_tool', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 404 + tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + ) + + +async def test_tool_detail_returns_pipeline_not_found_before_catalog_lookup(): + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock()) + pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=None)) + client = await _create_test_client(tool_mgr, pipeline_service) + + response = await client.get( + '/api/v1/tools/unique_tool?pipeline_uuid=missing-pipeline', + headers={'Authorization': 'Bearer test-token'}, + ) + + assert response.status_code == 404 + assert await response.get_json() == {'code': -1, 'msg': 'pipeline not found'} + pipeline_service.get_pipeline.assert_awaited_once_with('missing-pipeline') + tool_mgr.get_resolved_tool_catalog.assert_not_awaited() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index da9094572..254db172a 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -153,7 +153,6 @@ def make_app( host_root: str = '', workspace_quota_mb: int | None = None, enabled: bool = True, - force_box_session_id_template: str = '', ): box_config = { 'enabled': enabled, @@ -172,30 +171,107 @@ def make_app( return SimpleNamespace( logger=logger, - instance_config=SimpleNamespace( - data={ - 'box': box_config, - 'system': {'limitation': {'force_box_session_id_template': force_box_session_id_template}}, - } - ), + instance_config=SimpleNamespace(data={'box': box_config}), ) -def test_resolve_box_session_id_reads_current_runner_config(): +def test_resolve_box_session_id_is_host_owned(): query = make_query(101) query.pipeline_config = { 'ai': { - 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, - 'runner_config': { - 'plugin:langbot-team/LocalAgent/default': { - 'box-session-id-template': 'bot-{launcher_id}-{sender_id}', - }, - }, + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': {'plugin:test/runner/default': {}}, }, } service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) - assert service.resolve_box_session_id(query) == 'bot-test_user-test_user' + session_id = service.resolve_box_session_id(query) + assert session_id.startswith('lb-box-') + assert len(session_id) == 71 + assert set(session_id.removeprefix('lb-box-')) <= set('0123456789abcdef') + assert 'test_user' not in session_id + + +def test_resolve_box_session_id_is_stable_and_conversation_scoped(): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + first = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='group', + launcher_id='room-1', + bot_uuid='bot-1', + ) + same_conversation = pipeline_query.Query.model_construct( + query_id=2, + launcher_type='group', + launcher_id='room-1', + bot_uuid='bot-1', + ) + other_conversation = pipeline_query.Query.model_construct( + query_id=3, + launcher_type='group', + launcher_id='room-2', + bot_uuid='bot-1', + ) + + assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_conversation) + assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_conversation) + + +def test_resolve_box_session_id_prefers_private_host_scope(): + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + first = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='raw-launcher-a', + variables={'_host_box_scope': 'trusted-conversation'}, + ) + same_scope = pipeline_query.Query.model_construct( + query_id=2, + launcher_type='group', + launcher_id='raw-launcher-b', + variables={'_host_box_scope': 'trusted-conversation'}, + ) + other_scope = pipeline_query.Query.model_construct( + query_id=3, + launcher_type='person', + launcher_id='raw-launcher-a', + variables={'_host_box_scope': 'other-conversation'}, + ) + + assert service.resolve_box_session_id(first) == service.resolve_box_session_id(same_scope) + assert service.resolve_box_session_id(first) != service.resolve_box_session_id(other_scope) + + +def test_resolve_box_session_id_hashes_unsafe_unicode_and_long_identity(): + raw_identity = '用户/../../workspace/' + ('x' * 1000) + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='group/unsafe', + launcher_id=raw_identity, + variables={'_host_box_scope': raw_identity}, + ) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + session_id = service.resolve_box_session_id(query) + + assert session_id.startswith('lb-box-') + assert len(session_id) == 71 + assert session_id.isascii() + assert '/' not in session_id + assert '用户' not in session_id + + +def test_resolve_box_session_id_rejects_missing_private_host_scope(): + query = pipeline_query.Query.model_construct( + query_id=1, + launcher_type='person', + launcher_id='fallback-must-not-be-used', + variables={'_host_box_scope': None}, + ) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + + with pytest.raises(BoxValidationError, match='Host conversation scope'): + service.resolve_box_session_id(query) @pytest.mark.asyncio @@ -371,11 +447,13 @@ async def test_box_service_defaults_session_id_from_query(): service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - result = await service.execute_tool({'command': 'pwd'}, make_query(7)) + query = make_query(7) + expected_session_id = service.resolve_box_session_id(query) + result = await service.execute_tool({'command': 'pwd'}, query) - assert result['session_id'] == 'person_test_user' + assert result['session_id'] == expected_session_id assert result['ok'] is True - assert backend.start_calls == ['person_test_user'] + assert backend.start_calls == [expected_session_id] @pytest.mark.asyncio @@ -387,15 +465,16 @@ async def test_box_service_session_id_uses_query_attributes_without_variables(): await service.initialize() query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1') + expected_session_id = service.resolve_box_session_id(query) result = await service.execute_tool({'command': 'pwd'}, query) - assert result['session_id'] == 'group_room-1' + assert result['session_id'] == expected_session_id assert result['ok'] is True - assert backend.start_calls == ['group_room-1'] + assert backend.start_calls == [expected_session_id] @pytest.mark.asyncio -async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queries(): +async def test_box_service_session_id_fails_closed_without_session_context(): logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) @@ -403,92 +482,11 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri await service.initialize() query = pipeline_query.Query.model_construct(query_id=7) - result = await service.execute_tool({'command': 'pwd'}, query) - assert result['session_id'] == 'query_7' - assert result['ok'] is True - assert backend.start_calls == ['query_7'] + with pytest.raises(BoxValidationError, match='Host session context'): + await service.execute_tool({'command': 'pwd'}, query) - -@pytest.mark.asyncio -async def test_box_service_forced_global_scope_overrides_pipeline_template(): - """SaaS guard: a non-empty ``force_box_session_id_template`` pins every - query to one shared sandbox regardless of the pipeline's own scope.""" - logger = Mock() - backend = FakeBackend(logger) - runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=_InProcessBoxRuntimeClient(logger, runtime), - ) - await service.initialize() - - # Two distinct callers that would otherwise get separate sandboxes. - q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1') - q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice') - - r1 = await service.execute_tool({'command': 'pwd'}, q1) - r2 = await service.execute_tool({'command': 'pwd'}, q2) - - assert r1['session_id'] == 'global' - assert r2['session_id'] == 'global' - # Only one sandbox was ever started — the shared global one. - assert backend.start_calls == ['global'] - - -def test_box_service_forced_template_ignores_pipeline_config(): - """The forced template wins even when the pipeline explicitly sets a - per-user scope — proving the override is not bypassable via pipeline config.""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template='{global}'), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='person', - launcher_id='test_user', - sender_id='test_user', - pipeline_config={ - 'ai': { - 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, - 'runner_config': { - 'plugin:langbot-team/LocalAgent/default': { - 'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}' - } - }, - } - }, - ) - - assert service.resolve_box_session_id(query) == 'global' - - -def test_box_service_empty_forced_template_respects_pipeline_config(): - """An empty/whitespace forced template is a no-op: the pipeline's own - scope template is honoured (default non-SaaS behaviour).""" - logger = Mock() - service = BoxService( - make_app(logger, force_box_session_id_template=' '), - client=Mock(spec=BoxRuntimeClient), - ) - query = pipeline_query.Query.model_construct( - query_id=7, - launcher_type='group', - launcher_id='room-1', - pipeline_config={ - 'ai': { - 'runner': {'id': 'plugin:langbot-team/LocalAgent/default'}, - 'runner_config': { - 'plugin:langbot-team/LocalAgent/default': { - 'box-session-id-template': '{launcher_type}_{launcher_id}' - } - }, - } - }, - ) - - assert service.resolve_box_session_id(query) == 'group_room-1' + assert backend.start_calls == [] @pytest.mark.asyncio @@ -539,11 +537,13 @@ async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_pat service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() - result = await service.execute_tool({'command': 'pwd'}, make_query(15)) + query = make_query(15) + expected_session_id = service.resolve_box_session_id(query) + result = await service.execute_tool({'command': 'pwd'}, query) assert result['ok'] is True - assert backend.start_calls == ['person_test_user'] - assert backend.exec_calls == [('person_test_user', 'pwd')] + assert backend.start_calls == [expected_session_id] + assert backend.exec_calls == [(expected_session_id, 'pwd')] assert backend.start_specs[0].host_path == os.path.realpath(host_dir) @@ -606,41 +606,6 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): ) -class TestGetSystemGuidance: - """``get_system_guidance`` must ALWAYS advertise the per-query outbox path - when given a ``query_id`` — even with no inbound attachment — so files the - agent generates (QR codes, charts, rendered docs) are actually delivered. - - The wrapper collects the outbox on every turn regardless of inbound files; - before this, the agent was only told the outbox path inside the - inbound-attachment note, so pure-generation turns produced files that were - silently dropped. - """ - - def _service(self, logger=None): - logger = logger or Mock() - runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) - return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) - - def test_guidance_includes_outbox_when_query_id_given(self): - service = self._service() - guidance = service.get_system_guidance(42) - assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance - assert 'delivered to the user automatically' in guidance - - def test_guidance_omits_outbox_without_query_id(self): - service = self._service() - guidance = service.get_system_guidance() - assert service.OUTBOX_MOUNT_DIR not in guidance - # core exec guidance is still present - assert 'exec tool' in guidance - - def test_guidance_outbox_independent_of_inbound_attachments(self): - # A bare query_id (the pure-generation case) still gets the outbox note. - service = self._service() - assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0) - - @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): logger = Mock() @@ -1013,11 +978,13 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac await service.initialize() + query = make_query(45) + expected_session_id = service.resolve_box_session_id(query) with pytest.raises(BoxValidationError, match='workspace quota exceeded after execution'): - await service.execute_tool({'command': 'generate-output'}, make_query(45)) + await service.execute_tool({'command': 'generate-output'}, query) - assert backend.start_calls == ['person_test_user'] - assert backend.stop_calls == ['person_test_user'] + assert backend.start_calls == [expected_session_id] + assert backend.stop_calls == [expected_session_id] @pytest.mark.asyncio diff --git a/tests/unit_tests/command/test_operator.py b/tests/unit_tests/command/test_operator.py index a1d345292..0ac137445 100644 --- a/tests/unit_tests/command/test_operator.py +++ b/tests/unit_tests/command/test_operator.py @@ -198,7 +198,7 @@ class TestCommandOperatorBase: # Should not raise import asyncio - asyncio.get_event_loop().run_until_complete(op.initialize()) + asyncio.run(op.initialize()) def test_execute_is_abstract(self): """execute() must be implemented by subclass.""" diff --git a/tests/unit_tests/core/test_bootutils_deps.py b/tests/unit_tests/core/test_bootutils_deps.py index c57baaf4b..13fa156d7 100644 --- a/tests/unit_tests/core/test_bootutils_deps.py +++ b/tests/unit_tests/core/test_bootutils_deps.py @@ -28,7 +28,7 @@ class TestCheckDeps: import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) assert result == [] @@ -48,7 +48,7 @@ class TestCheckDeps: import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) assert 'requests' in result assert 'openai' in result @@ -64,7 +64,7 @@ class TestCheckDeps: import asyncio - result = asyncio.get_event_loop().run_until_complete(check_deps()) + result = asyncio.run(check_deps()) # Should include all required_deps keys assert len(result) == len(required_deps) @@ -111,7 +111,7 @@ class TestPrecheckPluginDeps: with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio - asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) + asyncio.run(precheck_plugin_deps()) mock_install.assert_not_called() @@ -134,6 +134,6 @@ class TestPrecheckPluginDeps: with patch('langbot.pkg.core.bootutils.deps.pkgmgr.install_requirements') as mock_install: import asyncio - asyncio.get_event_loop().run_until_complete(precheck_plugin_deps()) + asyncio.run(precheck_plugin_deps()) mock_install.assert_called_once_with('plugins/plugin1/requirements.txt', extra_params=[]) diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py index 499904d85..65ee0d11c 100644 --- a/tests/unit_tests/pipeline/test_chat_handler.py +++ b/tests/unit_tests/pipeline/test_chat_handler.py @@ -30,23 +30,9 @@ def mock_circular_import_chain(): make_pipeline_handler_import_mocks, get_handler_modules_to_clear, ) - from langbot_plugin.api.entities.builtin.provider.message import Message mocks = make_pipeline_handler_import_mocks() - # Create a default runner that yields a simple response - class DefaultRunner: - name = 'local-agent' - - def __init__(self, app, config): - self.app = app - self.config = config - - async def run(self, query): - yield Message(role='assistant', content='fake response') - - mocks['langbot.pkg.provider.runner'].preregistered_runners = [DefaultRunner] - clear = get_handler_modules_to_clear('chat') with isolated_sys_modules(mocks=mocks, clear=clear): @@ -56,22 +42,27 @@ def mock_circular_import_chain(): @pytest.fixture def fake_app(): """Create FakeApp instance.""" - import sys + from langbot_plugin.api.entities.builtin.provider.message import Message app = FakeApp() class FakeAgentRunOrchestrator: + runner_class = None + async def try_claim_steering_from_query(self, query): return False async def run_from_query(self, query): - runner_cls = sys.modules['langbot.pkg.provider.runner'].preregistered_runners[0] - runner = runner_cls(app, {}) + if self.runner_class is None: + yield Message(role='assistant', content='fake response') + return + + runner = self.runner_class(app, {}) async for result in runner.run(query): yield result def resolve_runner_id_for_telemetry(self, query): - return 'local-agent' + return 'plugin:langbot-team/LocalAgent/default' app.agent_run_orchestrator = FakeAgentRunOrchestrator() return app @@ -89,13 +80,11 @@ def mock_event_ctx(): @pytest.fixture -def set_runner(): - """Factory fixture to set a custom runner for tests.""" +def set_runner(fake_app): + """Configure the orchestrator test double for one test.""" def _set_runner(runner_class): - import sys - - sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_class] + fake_app.agent_run_orchestrator.runner_class = runner_class return _set_runner diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index d02025085..3ee9a2477 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -178,7 +178,7 @@ def test_runtime_pipeline_prefers_runner_mcp_resources(mock_app): 'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}], 'mcp-resource-agent-read-enabled': False, }, - } + }, } } pipeline_entity.extensions_preferences = { @@ -213,3 +213,95 @@ def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app): assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}] assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_runtime_pipeline_mcp_resource_read_flag_fails_closed(mock_app, invalid_value): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = { + 'ai': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'mcp-resource-agent-read-enabled': invalid_value, + } + }, + } + } + pipeline_entity.extensions_preferences = {'mcp_resource_agent_read_enabled': True} + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}]) +def test_runtime_pipeline_extension_enable_all_flags_fail_closed(mock_app, invalid_value): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = { + 'enable_all_plugins': invalid_value, + 'plugins': [{'author': 'allowed', 'name': 'plugin'}], + 'enable_all_mcp_servers': invalid_value, + 'mcp_servers': ['bound-mcp'], + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == ['allowed/plugin'] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == ['bound-mcp'] + + +@pytest.mark.parametrize('invalid_preferences', [None, [], '', 0, False]) +def test_runtime_pipeline_malformed_extension_root_disables_all_extensions( + mock_app, + invalid_preferences, +): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = invalid_preferences + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == [] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == [] + assert runtime_pipeline.mcp_resource_attachments == [] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +def test_runtime_pipeline_malformed_extension_lists_are_empty_allowlists(mock_app): + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {} + pipeline_entity.extensions_preferences = { + 'enable_all_plugins': True, + 'plugins': 'allowed/plugin', + 'enable_all_mcp_servers': True, + 'mcp_servers': 'bound-mcp', + 'mcp_resources': 'file:///README.md', + 'mcp_resource_agent_read_enabled': True, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.enable_all_plugins is False + assert runtime_pipeline.bound_plugins == [] + assert runtime_pipeline.enable_all_mcp_servers is False + assert runtime_pipeline.bound_mcp_servers == [] + assert runtime_pipeline.mcp_resource_attachments == [] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 0678fc704..accb5f44e 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -14,7 +14,6 @@ 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, @@ -113,7 +112,7 @@ class TestPreProcessorNormalText: app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) # Mock tool manager - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) # Mock plugin connector mock_event_ctx = Mock() @@ -151,7 +150,7 @@ class TestPreProcessorNormalText: mock_model = Mock() mock_model.model_entity = Mock(uuid='test-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -189,7 +188,7 @@ class TestPreProcessorEmptyMessage: app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -231,7 +230,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -279,7 +278,7 @@ class TestPreProcessorImageSegment: mock_model = Mock() mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call']) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -317,7 +316,7 @@ class TestPreProcessorModelSelection: 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=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) attach_agent_runner_descriptor(app) mock_event_ctx = Mock() @@ -369,7 +368,7 @@ class TestPreProcessorModelSelection: raise ValueError(f'Model {uuid} not found') app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) attach_agent_runner_descriptor(app) mock_event_ctx = Mock() @@ -411,7 +410,7 @@ class TestPreProcessorVariables: app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -448,7 +447,7 @@ class TestPreProcessorVariables: app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) - app.tool_mgr.get_all_tools = AsyncMock(return_value=[]) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) mock_event_ctx = Mock() mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) @@ -463,12 +462,62 @@ class TestPreProcessorVariables: assert 'group_name' in variables assert 'sender_name' in variables + @pytest.mark.asyncio + @pytest.mark.parametrize('invalid_value', [0, None, 'false']) + @pytest.mark.parametrize( + ('configured_skills', 'expected_skills'), + [ + (['bound-skill'], ['bound-skill']), + (None, []), + ('bound-skill', []), + ], + ) + async def test_malformed_enable_all_skills_flag_uses_bound_skills( + self, + invalid_value, + configured_skills, + expected_skills, + ): + 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) + + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=None) + app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[]) + app.pipeline_service.get_pipeline = AsyncMock( + return_value={ + 'extensions_preferences': { + 'enable_all_skills': invalid_value, + 'skills': configured_skills, + } + } + ) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + result = await preproc.PreProcessor(app).process(text_query('hello'), 'PreProcessor') + + assert result.new_query.variables['_pipeline_bound_skills'] == expected_skills + class TestPreProcessorToolSelection: - """Tests for Local Agent tool selection.""" + """Tests for generic AgentRunner tool selection.""" @pytest.mark.asyncio - async def test_local_agent_filters_selected_tools(self): + async def test_agent_runner_filters_selected_tools(self): """Only selected tools should be exposed when all-tools mode is off.""" preproc = get_preproc_module() @@ -488,11 +537,28 @@ class TestPreProcessorToolSelection: 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( + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( return_value=[ - SimpleNamespace(name='exec'), - SimpleNamespace(name='plugin_tool'), - SimpleNamespace(name='mcp_tool'), + { + 'name': 'exec', + 'source': 'builtin', + 'description': 'Execute', + 'parameters': {}, + }, + { + 'name': 'plugin_tool', + 'source': 'plugin', + 'source_id': 'test/plugin', + 'description': 'Plugin tool', + 'parameters': {}, + }, + { + 'name': 'mcp_tool', + 'source': 'mcp', + 'source_id': 'mcp-server', + 'description': 'MCP tool', + 'parameters': {}, + }, ] ) @@ -516,3 +582,53 @@ class TestPreProcessorToolSelection: result = await stage.process(query, 'PreProcessor') assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] + assert result.new_query.variables['_host_tool_source_refs'] == { + 'plugin_tool': {'source': 'plugin', 'source_id': 'test/plugin'}, + } + + +class TestPreProcessorMCPResourceContext: + """Tests for deferring MCP context until the run-scoped execution input.""" + + @pytest.mark.asyncio + async def test_pinned_context_does_not_mutate_preprocessed_input(self): + preproc = get_preproc_module() + from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter + + 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 = 'conversation-1' + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + mock_model = Mock() + mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=[]) + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + mcp_loader = Mock() + mcp_loader.build_resource_context_for_query = AsyncMock(return_value='Pinned documentation') + app.tool_mgr.mcp_tool_loader = mcp_loader + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + attach_agent_runner_descriptor(app, tool_calling=False) + + query = text_query('hello') + query.launcher_id = '12345' + query.pipeline_config = agent_runner_pipeline_config( + {'primary': 'primary-model-uuid', 'fallbacks': []}, + ) + + result = await preproc.PreProcessor(app).process(query, 'PreProcessor') + event = QueryEntryAdapter.query_to_event(result.new_query) + + assert event.input.text == 'hello' + assert 'Pinned documentation' not in str(event.input.contents) + mcp_loader.build_resource_context_for_query.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index e755303c9..32846bfb8 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -71,7 +71,10 @@ class TestEventRouteTrace: """Synthetic test dispatch runs the route but does not call the real adapter.""" import langbot_plugin.api.entities.builtin.provider.message as provider_message + captured_envelopes = [] + async def fake_run(envelope, binding): + captured_envelopes.append(envelope) yield provider_message.Message(role='assistant', content='test response') bot = self._make_bot( @@ -106,6 +109,7 @@ class TestEventRouteTrace: config={}, logger=bot.logger, send_message=AsyncMock(), + get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'get_group_info']), ) result = await bot.dispatch_test_event('message.received', {'chat_id': 'user-1', 'message_text': 'hello'}) @@ -114,6 +118,64 @@ class TestEventRouteTrace: assert result['dispatched'] is True assert result['status'] == 'delivered' assert result['suppressed_outputs'][0]['method'] == 'send_message' + assert captured_envelopes[0].delivery.supports_edit is False + assert captured_envelopes[0].delivery.supports_reaction is False + assert captured_envelopes[0].delivery.platform_capabilities['supported_apis'] == ['get_group_info'] + + @pytest.mark.asyncio + async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self): + """Persisted malformed Agent config cannot escape the per-event route boundary.""" + bot = self._make_bot( + [ + { + 'id': 'agent-binding', + 'enabled': True, + 'event_pattern': 'platform.member.joined', + 'target_type': 'agent', + 'target_uuid': 'agent-1', + 'priority': 0, + 'order': 0, + } + ] + ) + malformed_agent = { + 'uuid': 'agent-1', + 'kind': 'agent', + 'enabled': True, + 'supported_event_patterns': ['platform.member.joined'], + 'config': { + 'runner': {'id': 'runner-1'}, + 'runner_config': {'runner-1': ['invalid']}, + }, + } + valid_agent = { + **malformed_agent, + 'config': { + 'runner': {'id': 'runner-1'}, + 'runner_config': {'runner-1': {}}, + }, + } + runner_calls = [] + + async def fake_run(envelope, binding): + runner_calls.append((envelope, binding)) + if False: + yield None + + bot.ap = SimpleNamespace( + agent_service=SimpleNamespace(get_agent=AsyncMock(side_effect=[malformed_agent, valid_agent])), + agent_run_orchestrator=SimpleNamespace(run=fake_run), + ) + event = SimpleNamespace(type='platform.member.joined') + + failed = await bot._dispatch_eba_event_to_processor(event, Mock()) + delivered = await bot._dispatch_eba_event_to_processor(event, Mock()) + + assert failed['status'] == 'failed' + assert failed['failure_code'] == 'runner_failed' + assert failed['reason'] == 'Agent configuration is invalid' + assert delivered['status'] == 'delivered' + assert len(runner_calls) == 1 @pytest.mark.asyncio async def test_dispatch_test_event_pipeline_receives_synthetic_adapter(self): @@ -236,6 +298,41 @@ class TestEventRouteTrace: assert event.sender.nickname == 'QA User' assert str(event.message_chain) == 'hello' + def test_agent_envelope_projects_adapter_delivery_capabilities(self): + """Runner delivery context reflects the active adapter's declared APIs.""" + from langbot_plugin.api.entities.builtin.platform import entities, events, message + + bot = self._make_bot([]) + bot.bot_entity.uuid = 'bot-1' + adapter = SimpleNamespace( + get_supported_apis=Mock(return_value=['send_message', 'edit_message', 'add_reaction', 'edit_message', None]) + ) + event = events.MessageReceivedEvent( + message_id='message-1', + message_chain=message.MessageChain([message.Plain(text='hello')]), + sender=entities.User(id='user-1', nickname='QA User'), + chat_type=entities.ChatType.PRIVATE, + chat_id='user-1', + ) + + envelope = bot._eba_event_to_agent_envelope(event, adapter) + + assert envelope.delivery.supports_edit is True + assert envelope.delivery.supports_reaction is True + assert envelope.delivery.platform_capabilities == { + 'adapter': 'SimpleNamespace', + 'event_type': 'message.received', + 'supported_apis': ['send_message', 'edit_message', 'add_reaction'], + } + + def test_adapter_delivery_capabilities_degrade_on_invalid_declaration(self): + """Broken third-party capability declarations do not block event dispatch.""" + from langbot.pkg.platform.botmgr import RuntimeBot + + adapter = SimpleNamespace(get_supported_apis=Mock(side_effect=RuntimeError('broken manifest'))) + + assert RuntimeBot._get_adapter_supported_apis(adapter) == [] + class TestEventLoggerMetadata: """Test platform EventLogger metadata compatibility.""" @@ -381,7 +478,57 @@ class TestEBAEventBindings: assert binding.event_types == ['platform.member.joined'] assert binding.runner_id == 'plugin:test/runner/default' assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000} + assert binding.resource_policy.allow_all_tools is True + assert binding.resource_policy.allowed_tool_names is None assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_reply is True assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner'] assert binding.agent_id == 'agent-1' + + def test_agent_product_to_binding_projects_selected_tool_policy(self): + """Independent Agents use the same standard runner resource fields as Pipelines.""" + from langbot.pkg.platform.botmgr import RuntimeBot + + binding = RuntimeBot._agent_product_to_binding( + { + 'uuid': 'agent-1', + 'config': { + 'runner': {'id': 'plugin:test/runner/default'}, + 'runner_config': { + 'plugin:test/runner/default': { + 'enable-all-tools': False, + 'tools': ['exec', 'plugin_tool'], + 'knowledge-bases': ['kb-1'], + } + }, + }, + }, + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) + + assert binding is not None + assert binding.resource_policy.allow_all_tools is False + assert binding.resource_policy.allowed_tool_names == ['exec', 'plugin_tool'] + assert binding.resource_policy.allowed_kb_uuids == ['kb-1'] + + def test_agent_product_to_binding_does_not_fallback_to_component_ref(self): + """An empty config runner stays unconfigured even if component_ref is stale.""" + from langbot.pkg.platform.botmgr import RuntimeBot + + binding = RuntimeBot._agent_product_to_binding( + { + 'uuid': 'agent-1', + 'component_ref': 'plugin:test/stale/default', + 'config': { + 'runner': {'id': ''}, + 'runner_config': {}, + }, + }, + {'id': 'binding-1'}, + 'platform.member.joined', + 'bot-1', + ) + + assert binding is None diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index 98083b6af..c703e9a7d 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -270,6 +270,8 @@ async def test_telegram_converter_maps_bot_status_events(): 'can_invite_users': False, 'can_pin_messages': False, 'can_manage_topics': False, + 'can_edit_tag': False, + 'can_react_to_messages': False, 'until_date': 0, } invited = make_update( diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py index a2fdddd33..884ee74cf 100644 --- a/tests/unit_tests/plugin/test_handler.py +++ b/tests/unit_tests/plugin/test_handler.py @@ -32,6 +32,7 @@ class TestHandlerQueryVariables: app.logger = SimpleNamespace() app.logger.debug = MagicMock() + app.logger.warning = MagicMock() return app @@ -71,6 +72,90 @@ class TestHandlerQueryVariables: assert response.code == 0 assert mock_query.variables['test_var'] == 'test_value' + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'key', + [ + '_host_box_scope', + '_host_tool_source_refs', + '_pipeline_bound_plugins', + '_pipeline_bound_mcp_servers', + '_pipeline_bound_skills', + '_pipeline_mcp_resource_attachments', + '_pipeline_mcp_resource_agent_read_enabled', + '_activated_skills', + '_fallback_model_uuids', + '_monitoring_message_id', + '_sandbox_outbound_collected', + '_authorized_models', + '_permission_tools', + '_routed_by_rule', + ], + ) + async def test_set_query_var_rejects_host_reserved_keys(self, mock_app, key): + runtime_handler = make_handler(mock_app) + original_variables = {key: 'host-owned'} + mock_query = SimpleNamespace(variables=original_variables.copy()) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': 'plugin-overwrite', + } + ) + + assert response.code != 0 + assert response.message == f'Query variable {key!r} is reserved for LangBot Host' + assert mock_query.variables == original_variables + mock_app.logger.warning.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + 'key', + [ + 'business_context', + '_ltm_context', + '_knowledge_base_uuids', + '_skill_authoring_post_response_candidate', + ], + ) + async def test_set_query_var_keeps_plugin_business_variables_writable(self, mock_app, key): + runtime_handler = make_handler(mock_app) + mock_query = SimpleNamespace(variables={}) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': {'plugin': 'value'}, + } + ) + + assert response.code == 0 + assert mock_query.variables[key] == {'plugin': 'value'} + + @pytest.mark.asyncio + @pytest.mark.parametrize('key', ['', None, 7]) + async def test_set_query_var_rejects_invalid_key_shapes(self, mock_app, key): + runtime_handler = make_handler(mock_app) + mock_query = SimpleNamespace(variables={}) + mock_app.query_pool.cached_queries['test-query'] = mock_query + + response = await runtime_handler.actions[PluginToRuntimeAction.SET_QUERY_VAR.value]( + { + 'query_id': 'test-query', + 'key': key, + 'value': 'value', + } + ) + + assert response.code != 0 + assert response.message == 'Query variable key must be a non-empty string' + assert mock_query.variables == {} + @pytest.mark.asyncio async def test_get_query_var_success(self, mock_app): """Test get_query_var retrieves variable from query.""" @@ -205,10 +290,3 @@ class TestConstantsSemanticVersion: assert hasattr(constants, 'edition') assert constants.edition == 'community' - - def test_required_database_version_exists(self): - """Test database version constant.""" - from langbot.pkg.utils import constants - - assert hasattr(constants, 'required_database_version') - assert isinstance(constants.required_database_version, int) diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py index cfaeacd91..1c4dca7f2 100644 --- a/tests/unit_tests/plugin/test_handler_actions.py +++ b/tests/unit_tests/plugin/test_handler_actions.py @@ -10,6 +10,8 @@ import pytest from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError + def make_handler(app): """Create a RuntimeConnectionHandler with mocked external connection.""" @@ -456,6 +458,14 @@ class TestAgentRunProxyActions: mock_app.model_mgr.get_rerank_model_by_uuid = AsyncMock() mock_app.tool_mgr = Mock() mock_app.tool_mgr.execute_func_call = AsyncMock(return_value={'ok': True}) + mock_app.tool_mgr.get_tool_detail = AsyncMock( + return_value={ + 'name': 'test/search', + 'description': 'Search test data', + 'human_desc': 'Search', + 'parameters': {'type': 'object'}, + } + ) return mock_app @staticmethod @@ -463,9 +473,7 @@ class TestAgentRunProxyActions: return SimpleNamespace( pipeline_config={'output': {'misc': {'remove-think': remove_think}}}, variables={}, - prompt=SimpleNamespace( - messages=[provider_message.Message(role='system', content='effective prompt')] - ), + prompt=SimpleNamespace(messages=[provider_message.Message(role='system', content='effective prompt')]), ) @pytest.mark.asyncio @@ -491,10 +499,12 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - }) + response = await runtime_handler.actions[PluginToRuntimeAction.GET_PROMPT.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + } + ) finally: await registry.unregister(run_id) @@ -535,19 +545,23 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_001', - 'messages': [{'role': 'user', 'content': 'hello'}], - 'funcs': [{ - 'name': 'search', - 'human_desc': 'Search', - 'description': 'Search', - 'parameters': {'type': 'object'}, - }], - 'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1}, - }) + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'temperature': 0.7, 'presence_penalty': 0.1}, + } + ) finally: await registry.unregister(run_id) @@ -603,12 +617,14 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_usage_001', - 'messages': [{'role': 'user', 'content': 'hello'}], - }) + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) finally: await registry.unregister(run_id) @@ -647,19 +663,23 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_count_001', - 'messages': [{'role': 'user', 'content': 'hello'}], - 'funcs': [{ - 'name': 'search', - 'human_desc': 'Search', - 'description': 'Search', - 'parameters': {'type': 'object'}, - }], - 'extra_args': {'temperature': 0.7}, - }) + response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_count_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'temperature': 0.7}, + } + ) finally: await registry.unregister(run_id) @@ -692,12 +712,14 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_count_002', - 'messages': [{'role': 'user', 'content': 'hello'}], - }) + response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_count_002', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) finally: await registry.unregister(run_id) @@ -742,20 +764,24 @@ class TestAgentRunProxyActions: responses = [] try: - stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_stream_001', - 'messages': [{'role': 'user', 'content': 'hello'}], - 'funcs': [{ - 'name': 'search', - 'human_desc': 'Search', - 'description': 'Search', - 'parameters': {'type': 'object'}, - }], - 'extra_args': {'max_tokens': 256}, - 'remove_think': True, - }) + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + 'funcs': [ + { + 'name': 'search', + 'human_desc': 'Search', + 'description': 'Search', + 'parameters': {'type': 'object'}, + } + ], + 'extra_args': {'max_tokens': 256}, + 'remove_think': True, + } + ) async for response in stream: responses.append(response) finally: @@ -801,12 +827,14 @@ class TestAgentRunProxyActions: responses = [] try: - stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_stream_002', - 'messages': [{'role': 'user', 'content': 'hello'}], - }) + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_002', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) async for response in stream: responses.append(response) finally: @@ -856,12 +884,14 @@ class TestAgentRunProxyActions: responses = [] try: - stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'llm_model_uuid': 'llm_stream_usage_001', - 'messages': [{'role': 'user', 'content': 'hello'}], - }) + stream = runtime_handler.actions[PluginToRuntimeAction.INVOKE_LLM_STREAM.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'llm_model_uuid': 'llm_stream_usage_001', + 'messages': [{'role': 'user', 'content': 'hello'}], + } + ) async for response in stream: responses.append(response) finally: @@ -888,18 +918,28 @@ class TestAgentRunProxyActions: runner_id='plugin:test/runner/default', query_id=903, plugin_identity='test/runner', - resources=make_agent_resources(tools=[{'tool_name': 'test/search'}]), + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'test/search', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ), ) runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'tool_name': 'test/search', - 'parameters': {'q': 'langbot'}, - }) + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + 'parameters': {'q': 'langbot'}, + } + ) finally: await registry.unregister(run_id) @@ -909,8 +949,304 @@ class TestAgentRunProxyActions: name='test/search', parameters={'q': 'langbot'}, query=query, + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, ) + @pytest.mark.asyncio + async def test_get_tool_detail_passes_frozen_source_ref(self, app): + """GET_TOOL_DETAIL resolves only the implementation frozen for the run.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_get_tool_detail_source' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'test/search', + 'source': 'mcp', + 'source_id': 'bound-mcp', + } + ] + ), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.GET_TOOL_DETAIL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + app.tool_mgr.get_tool_detail.assert_awaited_once_with( + 'test/search', + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ('action', 'action_payload', 'manager_method'), + [ + pytest.param( + PluginToRuntimeAction.CALL_TOOL, + {'parameters': {'q': 'langbot'}}, + 'execute_func_call', + id='call', + ), + pytest.param( + PluginToRuntimeAction.GET_TOOL_DETAIL, + {}, + 'get_tool_detail', + id='detail', + ), + ], + ) + @pytest.mark.parametrize( + 'tool_resource', + [ + pytest.param( + {'tool_name': 'test/search', 'source_id': 'bound-mcp'}, + id='missing-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp'}, + id='missing-source-id', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 7, 'source_id': 'bound-mcp'}, + id='invalid-source-type', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp', 'source_id': 7}, + id='invalid-source-id-type', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'mcp', 'source_id': None}, + id='unscoped-mcp-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'plugin', 'source_id': None}, + id='unscoped-plugin-source', + ), + pytest.param( + {'tool_name': 'test/search', 'source': 'builtin', 'source_id': 'unexpected'}, + id='builtin-source-id', + ), + ], + ) + async def test_tool_actions_reject_malformed_frozen_source_identity( + self, + app, + action, + action_payload, + manager_method, + tool_resource, + ): + """Run-scoped tool actions never fall back from a malformed authorization snapshot.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = f'run_proxy_malformed_source_{action.value}' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[tool_resource]), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[action.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'test/search', + **action_payload, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code != 0 + assert response.message == 'Tool test/search has an invalid frozen source identity for this agent run' + getattr(app.tool_mgr, manager_method).assert_not_awaited() + + @pytest.mark.asyncio + async def test_call_tool_returns_error_when_host_denies_execution(self, app): + """CALL_TOOL preserves the existing error response when a loader denies execution.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_denied' + query = self.query() + app.query_pool.cached_queries[907] = query + app.tool_mgr.execute_func_call.side_effect = ToolExecutionDeniedError( + 'langbot_mcp_read_resource', + 'MCP resource agent reads are disabled', + ) + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=907, + plugin_identity='test/runner', + resources=make_agent_resources( + tools=[ + { + 'tool_name': 'langbot_mcp_read_resource', + 'source': 'mcp', + 'source_id': None, + } + ] + ), + ) + runtime_handler = make_handler(app) + + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'langbot_mcp_read_resource', + 'parameters': {'server_name': 'docs', 'uri': 'file:///README.md'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code != 0 + assert 'Failed to execute tool langbot_mcp_read_resource' in response.message + assert 'MCP resource agent reads are disabled' in response.message + + @pytest.mark.asyncio + async def test_call_tool_falls_back_to_host_execution_query(self, app): + """Pure EBA runs use their Host-only Query when no cached Query exists.""" + from langbot.pkg.agent.runner.session_registry import get_session_registry + + run_id = 'run_proxy_call_tool_event_first' + query = self.query() + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]), + execution_query=query, + ) + + runtime_handler = make_handler(app) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'exec', + 'parameters': {'command': 'pwd'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert getattr(query, '_agent_run_session')['run_id'] == run_id + app.tool_mgr.execute_func_call.assert_awaited_once_with( + name='exec', + parameters={'command': 'pwd'}, + query=query, + source_ref={'source': 'builtin', 'source_id': None}, + ) + + @pytest.mark.asyncio + async def test_pure_event_call_tool_exec_uses_native_host_path(self, app): + """A pure EBA run can call authorized native exec through CALL_TOOL.""" + from langbot.pkg.agent.runner.execution_context import build_execution_query + from langbot.pkg.agent.runner.host_models import AgentEventEnvelope + from langbot.pkg.agent.runner.session_registry import get_session_registry + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.toolmgr import ToolManager + from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext + from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput + + event = AgentEventEnvelope( + event_id='event-native-exec', + event_type='message.received', + source='platform', + bot_id='bot-1', + conversation_id='person_user-1', + input=AgentInput(text='run pwd'), + delivery=DeliveryContext( + surface='platform', + reply_target={'target_type': 'person', 'target_id': 'user-1'}, + platform_capabilities={'adapter': 'TestAdapter'}, + ), + ) + query = build_execution_query(event, []) + app.box_service = SimpleNamespace( + execute_tool=AsyncMock( + return_value={ + 'ok': True, + 'session_id': 'lb-box-test', + 'stdout': '/workspace\n', + 'stderr': '', + } + ), + ) + app.monitoring_service = None + app.skill_mgr = None + native_loader = NativeToolLoader(app) + native_loader._backend_available = True + tool_mgr = ToolManager(app) + tool_mgr.native_tool_loader = native_loader + tool_mgr.plugin_tool_loader = Mock() + tool_mgr.mcp_tool_loader = Mock() + tool_mgr.skill_tool_loader = Mock() + app.tool_mgr = tool_mgr + + run_id = 'run_pure_event_native_exec' + registry = get_session_registry() + await registry.unregister(run_id) + await registry.register( + run_id=run_id, + runner_id='plugin:test/runner/default', + query_id=None, + plugin_identity='test/runner', + resources=make_agent_resources(tools=[{'tool_name': 'exec', 'source': 'builtin', 'source_id': None}]), + execution_query=query, + ) + + runtime_handler = make_handler(app) + try: + response = await runtime_handler.actions[PluginToRuntimeAction.CALL_TOOL.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'tool_name': 'exec', + 'parameters': {'command': 'pwd'}, + } + ) + finally: + await registry.unregister(run_id) + + assert response.code == 0 + assert response.data['result']['ok'] is True + assert response.data['result']['stdout'] == '/workspace\n' + app.box_service.execute_tool.assert_awaited_once_with({'command': 'pwd'}, query) + @pytest.mark.asyncio async def test_invoke_rerank_uses_authorized_model_and_extra_args(self, app): """INVOKE_RERANK validates run-scoped model access and merges model extra_args.""" @@ -928,10 +1264,12 @@ class TestAgentRunProxyActions: ) provider = SimpleNamespace( - invoke_rerank=AsyncMock(return_value=[ - {'index': 0, 'relevance_score': 0.2}, - {'index': 1, 'relevance_score': 0.9}, - ]), + invoke_rerank=AsyncMock( + return_value=[ + {'index': 0, 'relevance_score': 0.2}, + {'index': 1, 'relevance_score': 0.9}, + ] + ), ) rerank_model = SimpleNamespace( model_entity=SimpleNamespace(extra_args={'top_n': 5, 'return_documents': False}), @@ -941,15 +1279,17 @@ class TestAgentRunProxyActions: runtime_handler = make_handler(app) try: - response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({ - 'run_id': run_id, - 'caller_plugin_identity': 'test/runner', - 'rerank_model_uuid': 'rerank_001', - 'query': 'hello', - 'documents': ['a', 'b'], - 'top_k': 1, - 'extra_args': {'top_n': 2}, - }) + response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]( + { + 'run_id': run_id, + 'caller_plugin_identity': 'test/runner', + 'rerank_model_uuid': 'rerank_001', + 'query': 'hello', + 'documents': ['a', 'b'], + 'top_k': 1, + 'extra_args': {'top_n': 2}, + } + ) finally: await registry.unregister(run_id) diff --git a/tests/unit_tests/provider/test_mcp_remote_transport.py b/tests/unit_tests/provider/test_mcp_remote_transport.py index b2f1d2e14..72e2d0708 100644 --- a/tests/unit_tests/provider/test_mcp_remote_transport.py +++ b/tests/unit_tests/provider/test_mcp_remote_transport.py @@ -15,6 +15,20 @@ from mcp import types as mcp_types from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession +@pytest.fixture(autouse=True) +def _isolate_loopback_transport_from_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep real loopback transport tests independent of workstation proxies.""" + for variable in ( + 'ALL_PROXY', + 'all_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + ): + monkeypatch.delenv(variable, raising=False) + + class _TransportProbe: def __init__(self, streamable_status: int | None) -> None: self.streamable_status = streamable_status diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py index 773efe71c..8cd279c82 100644 --- a/tests/unit_tests/provider/test_mcp_resources.py +++ b/tests/unit_tests/provider/test_mcp_resources.py @@ -8,9 +8,12 @@ import httpx import pytest from mcp import types as mcp_types +from langbot.pkg.agent.runner.execution_context import project_mcp_resource_config +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError from langbot.pkg.provider.tools.loaders.mcp import ( MCP_RESOURCE_CONTEXT_QUERY_KEY, MCP_RESOURCE_TRACE_QUERY_KEY, + MCP_READ_RESOURCE_SCHEMA, MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE, MCPLoader, @@ -257,24 +260,43 @@ async def test_mcp_loader_can_hide_synthetic_resource_tools(): @pytest.mark.asyncio -async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled(): +async def test_mcp_loader_get_tool_returns_synthetic_resource_schema(): + loader = MCPLoader(_app()) + loader.sessions = {'docs': _connected_session()} + + tool = await loader.get_tool(MCP_TOOL_READ_RESOURCE) + + assert tool is not None + assert tool.name == MCP_TOOL_READ_RESOURCE + assert tool.parameters == MCP_READ_RESOURCE_SCHEMA + + +@pytest.mark.asyncio +@pytest.mark.parametrize('read_enabled', [False, 0, None, 'false']) +@pytest.mark.parametrize( + ('tool_name', 'parameters'), + [ + (MCP_TOOL_LIST_RESOURCES, {'server_name': 'docs'}), + (MCP_TOOL_READ_RESOURCE, {'server_name': 'docs', 'uri': 'file:///README.md'}), + ], +) +async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled( + read_enabled, + tool_name, + parameters, +): 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 = SimpleNamespace(variables={'_pipeline_bound_mcp_servers': ['srv-1']}) + project_mcp_resource_config( query, + {'mcp-resource-agent-read-enabled': read_enabled}, ) - assert result[0].text == 'Error: MCP resource agent reads are disabled.' + with pytest.raises(ToolExecutionDeniedError, match='MCP resource agent reads are disabled'): + await loader.invoke_tool(tool_name, parameters, query) + session.session.read_resource.assert_not_called() @@ -321,3 +343,93 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re 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() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('enabled_config', 'expected_enabled'), + [ + pytest.param({}, True, id='missing'), + pytest.param({'enabled': True}, True, id='true'), + pytest.param({'enabled': False}, False, id='false'), + pytest.param({'enabled': 0}, False, id='zero'), + pytest.param({'enabled': None}, False, id='none'), + pytest.param({'enabled': 'false'}, False, id='string-false'), + ], +) +async def test_build_resource_context_attachment_enabled_fails_closed(enabled_config, expected_enabled): + loader = MCPLoader(_app()) + session = _connected_session(name='docs', uuid='srv-1') + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='enabled attachment', + ) + ] + ) + loader.sessions = {'docs': session} + attachment = { + 'server_uuid': 'srv-1', + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'mode': 'pinned', + **enabled_config, + } + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_attachments': [attachment], + } + ) + + context = await loader.build_resource_context_for_query(query) + + if expected_enabled: + assert 'enabled attachment' in context + session.session.read_resource.assert_awaited_once() + else: + assert context == '' + session.session.read_resource.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('field_name', 'invalid_value'), + [ + pytest.param('max_tokens', '100', id='string-tokens'), + pytest.param('max_tokens', 0, id='zero-tokens'), + pytest.param('max_tokens', -1, id='negative-tokens'), + pytest.param('max_tokens', True, id='boolean-tokens'), + pytest.param('max_bytes', '4096', id='string-bytes'), + pytest.param('max_bytes', 0, id='zero-bytes'), + pytest.param('max_bytes', -1, id='negative-bytes'), + pytest.param('max_bytes', True, id='boolean-bytes'), + ], +) +async def test_build_resource_context_invalid_attachment_limits_fail_closed(field_name, invalid_value): + ap = _app() + loader = MCPLoader(ap) + session = _connected_session(name='docs', uuid='srv-1') + loader.sessions = {'docs': session} + 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', + field_name: invalid_value, + } + ], + } + ) + + context = await loader.build_resource_context_for_query(query) + + assert context == '' + session.session.read_resource.assert_not_awaited() + ap.logger.warning.assert_called_once() diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 0c9fe9c44..bd2838787 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -193,11 +193,13 @@ class TestPersistActivatedSkill: query = SimpleNamespace(variables={ACTIVATED_SKILLS_KEY: {'pdf': {'name': 'pdf'}}}) query._agent_run_session = { - 'runner_id': 'plugin:langbot-team/LocalAgent/default', - 'state_context': { - 'scope_keys': {'conversation': 'conv-scope-key'}, - 'binding_identity': 'binding-1', - 'conversation_id': 'c1', + 'runner_id': 'plugin:test/runner/default', + 'authorization': { + 'state_context': { + 'scope_keys': {'conversation': 'conv-scope-key'}, + 'binding_identity': 'binding-1', + 'conversation_id': 'c1', + }, }, } @@ -214,7 +216,7 @@ class TestPersistActivatedSkill: assert kwargs['state_key'] == ACTIVATED_SKILL_NAMES_STATE_KEY assert kwargs['value'] == ['pdf'] assert kwargs['scope'] == 'conversation' - assert kwargs['runner_id'] == 'plugin:langbot-team/LocalAgent/default' + assert kwargs['runner_id'] == 'plugin:test/runner/default' assert kwargs['binding_identity'] == 'binding-1' @pytest.mark.asyncio diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py index 0ae33115c..a999f237c 100644 --- a/tests/unit_tests/provider/test_tool_manager.py +++ b/tests/unit_tests/provider/test_tool_manager.py @@ -7,12 +7,15 @@ Tests cover: from __future__ import annotations -import pytest -from unittest.mock import Mock, AsyncMock from importlib import import_module +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query +import pytest + +from langbot.pkg.provider.tools.errors import ToolExecutionDeniedError def get_toolmgr_module(): @@ -259,6 +262,119 @@ class TestToolManagerExecuteFuncCall: mock_plugin_loader.invoke_tool.assert_called_once() mock_mcp_loader.invoke_tool.assert_not_called() + @pytest.mark.asyncio + async def test_bound_mcp_source_wins_over_unbound_plugin_name_collision(self, mock_app_with_loaders): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_plugin_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + query = Mock(variables={}) + + result = await manager.execute_func_call( + 'shared_tool', + {'value': 1}, + query, + source_ref={'source': 'mcp', 'source_id': 'bound-mcp'}, + ) + + assert result == 'mcp_result' + mock_plugin_loader.has_tool.assert_not_awaited() + mock_plugin_loader.invoke_tool.assert_not_awaited() + mock_mcp_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='bound-mcp') + mock_mcp_loader.invoke_tool.assert_awaited_once_with( + 'shared_tool', + {'value': 1}, + query, + source_id='bound-mcp', + ) + + @pytest.mark.asyncio + async def test_bound_plugin_source_selects_one_of_two_same_name_plugins(self, mock_app_with_loaders): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_plugin_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + query = Mock(variables={}) + + result = await manager.execute_func_call( + 'shared_tool', + {}, + query, + source_ref={'source': 'plugin', 'source_id': 'authorized/plugin'}, + ) + + assert result == 'plugin_result' + mock_plugin_loader.has_tool.assert_awaited_once_with('shared_tool', source_id='authorized/plugin') + mock_plugin_loader.invoke_tool.assert_awaited_once_with( + 'shared_tool', + {}, + query, + source_id='authorized/plugin', + ) + mock_mcp_loader.has_tool.assert_not_awaited() + + @pytest.mark.asyncio + async def test_disabled_synthetic_mcp_tool_is_monitored_as_error( + self, + mock_app_with_loaders, + sample_query, + ): + toolmgr = get_toolmgr_module() + mock_app, mock_plugin_loader, mock_mcp_loader = mock_app_with_loaders + mock_app.monitoring_service = SimpleNamespace(record_tool_call=AsyncMock()) + sample_query.variables = {} + sample_query.launcher_type = None + sample_query.launcher_id = None + sample_query.bot_uuid = 'bot-1' + mock_mcp_loader.has_tool = AsyncMock(return_value=True) + mock_mcp_loader.invoke_tool = AsyncMock( + side_effect=ToolExecutionDeniedError( + 'langbot_mcp_read_resource', + 'MCP resource agent reads are disabled', + ) + ) + manager = toolmgr.ToolManager(mock_app) + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) + + with pytest.raises(ToolExecutionDeniedError): + await manager.execute_func_call( + 'langbot_mcp_read_resource', + {'server_name': 'docs', 'uri': 'file:///README.md'}, + sample_query, + source_ref={'source': 'mcp', 'source_id': None}, + ) + + record = mock_app.monitoring_service.record_tool_call + record.assert_awaited_once() + assert record.await_args.kwargs['status'] == 'error' + assert record.await_args.kwargs['tool_source'] == 'mcp' + assert 'MCP resource agent reads are disabled' in record.await_args.kwargs['error_message'] + + +class TestToolManagerSourceResolution: + @pytest.mark.asyncio + @pytest.mark.parametrize('source', ['mcp', 'plugin']) + async def test_same_name_implementations_in_one_scope_are_hidden(self, source): + toolmgr = get_toolmgr_module() + app = Mock(logger=Mock()) + manager = toolmgr.ToolManager(app) + manager.get_tool_catalog = AsyncMock( + return_value=[ + {'name': 'shared_tool', 'source': source, 'source_id': 'source-a'}, + {'name': 'shared_tool', 'source': source, 'source_id': 'source-b'}, + {'name': 'unique_tool', 'source': 'builtin'}, + ] + ) + + catalog = await manager.get_resolved_tool_catalog() + + assert [item['name'] for item in catalog] == ['unique_tool'] + app.logger.warning.assert_called_once() + class TestToolManagerShutdown: """Tests for shutdown method.""" diff --git a/tests/unit_tests/provider/test_tool_source_routing.py b/tests/unit_tests/provider/test_tool_source_routing.py new file mode 100644 index 000000000..4365263c4 --- /dev/null +++ b/tests/unit_tests/provider/test_tool_source_routing.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.provider.tools.loaders.mcp import ( + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + MCPLoader, + MCPSessionStatus, +) +from langbot.pkg.provider.tools.loaders.plugin import PluginToolLoader +from langbot_plugin.api.entities.builtin.resource.tool import LLMTool + + +def make_tool(name: str) -> LLMTool: + return LLMTool( + name=name, + human_desc=name, + description=name, + parameters={'type': 'object', 'properties': {}}, + func=lambda parameters: parameters, + ) + + +@pytest.mark.asyncio +async def test_two_mcp_servers_with_same_tool_route_to_authorized_server(): + tool = make_tool('shared_tool') + first = SimpleNamespace( + server_uuid='mcp-a', + get_tools=Mock(return_value=[tool]), + invoke_mcp_tool=AsyncMock(return_value='from-a'), + ) + second = SimpleNamespace( + server_uuid='mcp-b', + get_tools=Mock(return_value=[tool]), + invoke_mcp_tool=AsyncMock(return_value='from-b'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'first': first, 'second': second} + query = SimpleNamespace(variables={}) + + result = await loader.invoke_tool( + 'shared_tool', + {'value': 1}, + query, + source_id='mcp-b', + ) + + assert result == 'from-b' + first.invoke_mcp_tool.assert_not_awaited() + second.invoke_mcp_tool.assert_awaited_once_with( + 'shared_tool', + {'value': 1}, + query=query, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reserved_name', [MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE]) +async def test_reserved_name_routes_to_real_mcp_tool_when_source_id_is_present(reserved_name): + real_tool = make_tool(reserved_name) + session = SimpleNamespace( + server_uuid='srv-real', + get_tools=Mock(return_value=[real_tool]), + invoke_mcp_tool=AsyncMock(return_value='real-result'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'real': session} + query = SimpleNamespace(variables={}) + + assert await loader.has_tool(reserved_name, source_id='srv-real') is True + assert await loader.get_tool(reserved_name, source_id='srv-real') is real_tool + result = await loader.invoke_tool(reserved_name, {}, query, source_id='srv-real') + + assert result == 'real-result' + session.invoke_mcp_tool.assert_awaited_once_with(reserved_name, {}, query=query) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('reserved_name', 'invoke_method'), + [ + (MCP_TOOL_LIST_RESOURCES, '_invoke_mcp_list_resources'), + (MCP_TOOL_READ_RESOURCE, '_invoke_mcp_read_resource'), + ], +) +async def test_reserved_name_uses_host_synthetic_tool_when_source_id_is_none( + reserved_name, + invoke_method, +): + real_tool = make_tool(reserved_name) + session = SimpleNamespace( + server_uuid='srv-real', + server_name='real', + enable=True, + status=MCPSessionStatus.CONNECTED, + session=object(), + get_tools=Mock(return_value=[real_tool]), + has_resource_support=Mock(return_value=True), + invoke_mcp_tool=AsyncMock(return_value='real-result'), + ) + loader = MCPLoader(SimpleNamespace(logger=Mock())) + loader.sessions = {'real': session} + synthetic_invoke = AsyncMock(return_value='synthetic-result') + setattr(loader, invoke_method, synthetic_invoke) + query = SimpleNamespace(variables={}) + + assert await loader.has_tool(reserved_name, source_id=None) is True + tool = await loader.get_tool(reserved_name, source_id=None) + result = await loader.invoke_tool(reserved_name, {}, query, source_id=None) + + assert tool is not None + assert tool is not real_tool + assert result == 'synthetic-result' + synthetic_invoke.assert_awaited_once_with({}, query) + session.invoke_mcp_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_two_plugins_with_same_tool_forward_only_authorized_plugin(): + manifest = SimpleNamespace( + owner='authorized/plugin', + metadata=SimpleNamespace( + name='shared_tool', + description=SimpleNamespace(en_US='Shared tool'), + ), + spec={ + 'llm_prompt': 'Shared tool', + 'parameters': {'type': 'object', 'properties': {}}, + }, + ) + connector = SimpleNamespace( + list_tools=AsyncMock(return_value=[manifest]), + call_tool=AsyncMock(return_value='authorized-result'), + ) + loader = PluginToolLoader(SimpleNamespace(plugin_connector=connector, logger=Mock())) + query = SimpleNamespace(session=SimpleNamespace(), query_id=7) + query.session.model_dump = Mock(return_value={}) + + result = await loader.invoke_tool( + 'shared_tool', + {}, + query, + source_id='authorized/plugin', + ) + + assert result == 'authorized-result' + connector.call_tool.assert_awaited_once_with( + 'shared_tool', + {}, + session=query.session, + query_id=7, + bound_plugins=['authorized/plugin'], + ) diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index c070dcd58..256dc8cb8 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -102,7 +102,7 @@ def _make_app(*, skill_service) -> SimpleNamespace: session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1') conversation = _make_conversation() model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'})) - tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) + tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[])) return SimpleNamespace( sess_mgr=SimpleNamespace( @@ -159,9 +159,10 @@ async def test_preproc_loads_host_tools_for_runner(): 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( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, + include_skill_authoring=True, include_mcp_resource_tools=True, ) @@ -172,10 +173,20 @@ async def test_preproc_puts_host_skill_tools_into_query_scope(): preproc_module, entities_module = _import_preproc_modules() app = _make_app(skill_service=SimpleNamespace()) - app.tool_mgr.get_all_tools = AsyncMock( + app.tool_mgr.get_resolved_tool_catalog = AsyncMock( return_value=[ - SimpleNamespace(name='activate'), - SimpleNamespace(name='register_skill'), + { + 'name': 'activate', + 'source': 'skill', + 'description': 'Activate a skill', + 'parameters': {}, + }, + { + 'name': 'register_skill', + 'source': 'skill', + 'description': 'Register a skill', + 'parameters': {}, + }, ] ) query = _make_query() @@ -184,9 +195,10 @@ async def test_preproc_puts_host_skill_tools_into_query_scope(): result = await stage.process(query, 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, + include_skill_authoring=True, include_mcp_resource_tools=True, ) assert [tool.name for tool in query.use_funcs] == ['activate', 'register_skill'] @@ -203,9 +215,10 @@ async def test_preproc_loads_host_tools_regardless_of_skill_service(): 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( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, + include_skill_authoring=True, include_mcp_resource_tools=True, ) @@ -222,9 +235,10 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable result = await stage.process(query, 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with( + app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with( None, None, + include_skill_authoring=True, include_mcp_resource_tools=False, ) diff --git a/tests/unit_tests/vector/test_mgr.py b/tests/unit_tests/vector/test_mgr.py index 5c8927b54..0b6d017d7 100644 --- a/tests/unit_tests/vector/test_mgr.py +++ b/tests/unit_tests/vector/test_mgr.py @@ -56,7 +56,7 @@ class TestVectorDBManagerInitialization: # Run initialize synchronously for test import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) # Chroma should be instantiated mock_chroma_class.assert_called_once_with(mock_app) @@ -78,7 +78,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) mock_app.logger.info.assert_called() @@ -99,7 +99,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_qdrant_class.assert_called_once_with(mock_app) @@ -119,7 +119,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_seekdb_class.assert_called_once_with(mock_app) @@ -138,7 +138,8 @@ class TestVectorDBManagerInitialization: mgr = VectorDBManager(mock_app) import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + + asyncio.run(mgr.initialize()) mock_valkey_class.assert_called_once_with(mock_app) @@ -161,7 +162,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_milvus_class.assert_called_once_with( mock_app, uri='http://localhost:19530', token='root:Milvus', db_name='langbot_db' @@ -183,7 +184,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) # Should use default values mock_milvus_class.assert_called_once_with(mock_app, uri='./data/milvus.db', token=None, db_name='default') @@ -204,7 +205,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, connection_string='postgresql://user:pass@host:5432/langbot' @@ -235,7 +236,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret' @@ -257,7 +258,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_pgvector_class.assert_called_once_with( mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres' @@ -279,7 +280,7 @@ class TestVectorDBManagerInitialization: import asyncio - asyncio.get_event_loop().run_until_complete(mgr.initialize()) + asyncio.run(mgr.initialize()) mock_chroma_class.assert_called_once_with(mock_app) mock_app.logger.warning.assert_called() diff --git a/tests/unit_tests/vector/test_valkey_search_filter.py b/tests/unit_tests/vector/test_valkey_search_filter.py index 7439712dd..1da53fe9a 100644 --- a/tests/unit_tests/vector/test_valkey_search_filter.py +++ b/tests/unit_tests/vector/test_valkey_search_filter.py @@ -357,10 +357,12 @@ class TestCredentialsBuild: cred_calls.append(kwargs) return ('CRED', kwargs) - monkeypatch.setattr(mod, 'GlideClient', _FakeClient) - monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials) - monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw) - monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k)) + # The optional glide import defines none of these names when the + # dependency is absent, which is the normal fast-test environment. + 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): diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py index 427df9f19..638a0e8d0 100644 --- a/tests/unit_tests/vector/test_vdb_base.py +++ b/tests/unit_tests/vector/test_vdb_base.py @@ -115,7 +115,7 @@ class TestVectorDatabaseAbstractMethods: # list_by_filter should return empty list and -1 for total import asyncio - result = asyncio.get_event_loop().run_until_complete(db.list_by_filter('test_collection')) + result = asyncio.run(db.list_by_filter('test_collection')) assert result == ([], -1) diff --git a/tests/utils/import_isolation.py b/tests/utils/import_isolation.py index d3200edee..47fce42bc 100644 --- a/tests/utils/import_isolation.py +++ b/tests/utils/import_isolation.py @@ -154,7 +154,6 @@ def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]: 'langbot.pkg.pipeline.controller': MagicMock(), 'langbot.pkg.pipeline.pipelinemgr': MagicMock(), 'langbot.pkg.pipeline.process.process': MagicMock(), - 'langbot.pkg.provider.runner': MagicMock(preregistered_runners=[]), 'langbot.pkg.utils.importutil': mock_importutil, } diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 60c077ad5..5988f4066 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -17,6 +17,7 @@ export default function AgentDetailContent({ id }: { id: string }) { const [agent, setAgent] = useState(null); const [loading, setLoading] = useState(!isCreateMode); const [formDirty, setFormDirty] = useState(false); + const [formSaving, setFormSaving] = useState(false); useEffect(() => { if (isCreateMode) { @@ -73,7 +74,11 @@ export default function AgentDetailContent({ id }: { id: string }) {

{t('agents.editAgent')}

-
@@ -83,13 +88,13 @@ export default function AgentDetailContent({ id }: { id: string }) { agentId={id} onFinish={() => { refreshPipelines(); - setFormDirty(false); }} onDeleted={() => { refreshPipelines(); navigate('/home/agents'); }} onDirtyChange={setFormDirty} + onSavingChange={setFormSaving} />
diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 953a67224..c17c6e28c 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -62,6 +62,7 @@ interface AgentFormComponentProps { onFinish: () => void; onDeleted: () => void; onDirtyChange?: (dirty: boolean) => void; + onSavingChange?: (saving: boolean) => void; } interface SectionItem { @@ -75,6 +76,7 @@ export default function AgentFormComponent({ onFinish, onDeleted, onDirtyChange, + onSavingChange, }: AgentFormComponentProps) { const { t } = useTranslation(); const [activeSection, setActiveSection] = @@ -86,6 +88,8 @@ export default function AgentFormComponent({ const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusError, setPluginStatusError] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const isSavingRef = useRef(false); const formSchema = z.object({ basic: z.object({ @@ -118,10 +122,10 @@ export default function AgentFormComponent({ const savedSnapshotRef = useRef(''); const initializedStagesRef = useRef>(new Set()); const watchedValues = form.watch(); - const hasUnsavedChanges = useMemo(() => { + const hasUnsavedChanges = (() => { if (!savedSnapshotRef.current) return false; return JSON.stringify(watchedValues) !== savedSnapshotRef.current; - }, [watchedValues]); + })(); useEffect(() => { onDirtyChange?.(hasUnsavedChanges); @@ -388,6 +392,8 @@ export default function AgentFormComponent({ } function handleSubmit(values: FormValues) { + if (isSavingRef.current) return; + const submittedSnapshot = JSON.stringify(values); const runner = values.runner || {}; const agent: Partial = { name: values.basic.name, @@ -404,16 +410,23 @@ export default function AgentFormComponent({ }, }; + isSavingRef.current = true; + setIsSaving(true); + onSavingChange?.(true); httpClient .updateAgent(agentId, agent) .then(() => { - const snapshotValues = form.getValues(); - savedSnapshotRef.current = JSON.stringify(snapshotValues); + savedSnapshotRef.current = submittedSnapshot; onFinish(); toast.success(t('agents.saveSuccess')); }) .catch((err) => { toast.error(t('agents.saveError') + err.msg); + }) + .finally(() => { + isSavingRef.current = false; + setIsSaving(false); + onSavingChange?.(false); }); } @@ -574,6 +587,7 @@ export default function AgentFormComponent({ type="button" variant="destructive" size="sm" + disabled={isSaving} onClick={() => setShowDeleteConfirm(true)} > diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index c114778c8..bf0980508 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -1561,7 +1561,11 @@ export default function EventBindingsEditor({ function toggleExpand(id: string) { setExpandedIds((prev) => { const s = new Set(prev); - s.has(id) ? s.delete(id) : s.add(id); + if (s.has(id)) { + s.delete(id); + } else { + s.add(id); + } return s; }); } diff --git a/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx index 9b138c140..63a7e8266 100644 --- a/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx +++ b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx @@ -106,6 +106,25 @@ function getBoundPluginId(plugin: BoundPlugin) { return `${plugin.author || ''}/${plugin.name}`; } +function getGlobalExtensions( + plugins: AvailablePlugin[], + mcpServers: MCPServer[], +): PipelineExtensions { + return { + enable_all_plugins: true, + enable_all_mcp_servers: true, + enable_all_skills: true, + mcp_resource_agent_read_enabled: true, + bound_plugins: [], + available_plugins: plugins, + bound_mcp_servers: [], + available_mcp_servers: mcpServers, + bound_mcp_resources: [], + bound_skills: [], + available_skills: [], + }; +} + function InfoTooltip({ label }: { label: string }) { return ( @@ -348,19 +367,60 @@ export default function ToolResourceSelectors({ const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]); useEffect(() => { + let cancelled = false; + setTools([]); + setKnowledgeBases([]); + setExtensions(null); + if (mode !== 'resources') { - backendClient.getTools(pipelineId).then((resp) => setTools(resp.tools)); + backendClient + .getTools(pipelineId) + .then((resp) => { + if (!cancelled) setTools(resp.tools); + }) + .catch(() => { + if (!cancelled) setTools([]); + }); } if (mode !== 'tools') { backendClient .getKnowledgeBases() - .then((resp) => setKnowledgeBases(resp.bases)); + .then((resp) => { + if (!cancelled) setKnowledgeBases(resp.bases); + }) + .catch(() => { + if (!cancelled) setKnowledgeBases([]); + }); } if (pipelineId) { backendClient .getPipelineExtensions(pipelineId) - .then((resp) => setExtensions(resp)); + .then((resp) => { + if (!cancelled) setExtensions(resp); + }) + .catch(() => { + if (!cancelled) setExtensions(null); + }); + } else { + Promise.all([ + backendClient + .getPlugins() + .catch(() => ({ plugins: [] as AvailablePlugin[] })), + backendClient + .getMCPServers() + .catch(() => ({ servers: [] as MCPServer[] })), + ]).then(([pluginResp, mcpResp]) => { + if (!cancelled) { + setExtensions( + getGlobalExtensions(pluginResp.plugins, mcpResp.servers), + ); + } + }); } + + return () => { + cancelled = true; + }; }, [mode, pipelineId]); const enableAllTools = value['enable-all-tools'] !== false; @@ -480,11 +540,6 @@ export default function ToolResourceSelectors({ ], ); - const availableToolNames = useMemo( - () => new Set(availableTools.map((tool) => tool.name)), - [availableTools], - ); - const resourceServers = useMemo(() => { return scopedMCPServers.filter( (server) => @@ -513,6 +568,9 @@ export default function ToolResourceSelectors({ ? availableMCPResourceKeys.has(getMCPResourceKey(server, resource.uri)) : false; }); + const unavailableSelectedMCPResources = selectedMCPResources.filter( + (resource) => !scopedSelectedMCPResources.includes(resource), + ); const selectedTools = selectedToolNames .map((name: string) => availableTools.find((tool) => tool.name === name)) @@ -524,10 +582,10 @@ export default function ToolResourceSelectors({ const sourceLabels = useMemo>( () => ({ - builtin: t('pipelines.localAgent.builtinTools'), - plugin: t('pipelines.localAgent.pluginTools'), - mcp: t('pipelines.localAgent.mcpTools'), - skill: t('pipelines.localAgent.skillTools'), + builtin: t('pipelines.agentRunner.builtinTools'), + plugin: t('pipelines.agentRunner.pluginTools'), + mcp: t('pipelines.agentRunner.mcpTools'), + skill: t('pipelines.agentRunner.skillTools'), }), [t], ); @@ -546,11 +604,7 @@ export default function ToolResourceSelectors({ }; const handleConfirmTools = () => { - onChange({ - tools: tempSelectedToolNames.filter((name) => - availableToolNames.has(name), - ), - }); + onChange({ tools: tempSelectedToolNames }); setToolsDialogOpen(false); }; @@ -580,7 +634,9 @@ export default function ToolResourceSelectors({ : scopedSelectedMCPResources.filter( (item) => !isSameMCPResource(item, server, resource.uri), ); - onChange({ 'mcp-resources': next }); + onChange({ + 'mcp-resources': [...unavailableSelectedMCPResources, ...next], + }); }; const isMCPResourceSelected = (server: MCPServer, uri: string) => @@ -597,25 +653,27 @@ export default function ToolResourceSelectors({

- {t('pipelines.localAgent.toolsTitle')} + {t('pipelines.agentRunner.toolsTitle')}

- + {pipelineId && ( + + )}

- {t('pipelines.localAgent.toolsDescription')} + {t('pipelines.agentRunner.toolsDescription')}

@@ -625,13 +683,13 @@ export default function ToolResourceSelectors({ {enableAllTools ? (

- {t('pipelines.localAgent.allToolsEnabled')} + {t('pipelines.agentRunner.allToolsEnabled')}

) : selectedTools.length === 0 ? (

- {t('pipelines.localAgent.noToolsSelected')} + {t('pipelines.agentRunner.noToolsSelected')}

) : ( @@ -701,16 +759,12 @@ export default function ToolResourceSelectors({ className="w-full" disabled={enableAllTools} onClick={() => { - setTempSelectedToolNames( - selectedToolNames.filter((name: string) => - availableToolNames.has(name), - ), - ); + setTempSelectedToolNames(selectedToolNames); setToolsDialogOpen(true); }} > - {t('pipelines.localAgent.editTools')} + {t('pipelines.agentRunner.editTools')}
)} @@ -719,10 +773,10 @@ export default function ToolResourceSelectors({

- {t('pipelines.localAgent.resourcesTitle')} + {t('pipelines.agentRunner.resourcesTitle')}

- {t('pipelines.localAgent.resourcesDescription')} + {t('pipelines.agentRunner.resourcesDescription')}

@@ -731,7 +785,7 @@ export default function ToolResourceSelectors({
- {t('pipelines.localAgent.knowledgeBases')} + {t('pipelines.agentRunner.knowledgeBases')}
onChange({ 'mcp-resource-agent-read-enabled': checked }) @@ -830,7 +886,7 @@ export default function ToolResourceSelectors({ {resourceServers.length === 0 ? (

- {t('pipelines.localAgent.noMCPResourcesAvailable')} + {t('pipelines.agentRunner.noMCPResourcesAvailable')}

) : ( @@ -900,7 +956,9 @@ export default function ToolResourceSelectors({ - {t('pipelines.localAgent.selectTools')} + + {t('pipelines.agentRunner.selectTools')} +
{availableToolGroups.map((sourceGroup) => { @@ -910,15 +968,17 @@ export default function ToolResourceSelectors({ {sourceGroup.label} - {sourceGroup.key === 'mcp' && ( + {pipelineId && sourceGroup.key === 'mcp' && ( )} {sourceGroup.key === 'skill' && ( )} @@ -987,7 +1047,7 @@ export default function ToolResourceSelectors({ {availableToolGroups.length === 0 && (

- {t('pipelines.localAgent.noToolsSelected')} + {t('pipelines.agentRunner.noToolsSelected')}

)} @@ -1012,7 +1072,7 @@ export default function ToolResourceSelectors({ - {t('pipelines.localAgent.selectKnowledgeBases')} + {t('pipelines.agentRunner.selectKnowledgeBases')}
diff --git a/web/src/app/home/monitoring/components/MessageDetailsCard.tsx b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx index 9f067943e..9e78c394c 100644 --- a/web/src/app/home/monitoring/components/MessageDetailsCard.tsx +++ b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx @@ -9,10 +9,6 @@ interface MessageDetailsCardProps { export function MessageDetailsCard({ details }: MessageDetailsCardProps) { const { t } = useTranslation(); - const isLocalAgent = [ - 'local-agent', - 'plugin:langbot-team/LocalAgent/default', - ].includes(details.message?.runnerName ?? ''); // Parse query variables JSON string const queryVariables = useMemo(() => { @@ -205,49 +201,45 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
)} - {/* Query Variables Section - Only show for non-local-agent runners */} - {queryVariables && - Object.keys(queryVariables).length > 0 && - !isLocalAgent && ( -
-

- - {t('monitoring.queryVariables.title')} -

-
- {Object.entries(queryVariables).map(([key, value]) => ( -
-
{key}
-
- {value === null || value === undefined ? ( - null - ) : typeof value === 'string' ? ( - value || ( - - empty - - ) - ) : ( - JSON.stringify(value) - )} -
+ {/* Query Variables Section */} + {queryVariables && Object.keys(queryVariables).length > 0 && ( +
+

+ + {t('monitoring.queryVariables.title')} +

+
+ {Object.entries(queryVariables).map(([key, value]) => ( +
+
{key}
+
+ {value === null || value === undefined ? ( + null + ) : typeof value === 'string' ? ( + value || ( + + empty + + ) + ) : ( + JSON.stringify(value) + )}
- ))} -
+
+ ))}
- )} +
+ )} {/* No data message */} {(!details.llmCalls || details.llmCalls.length === 0) && (!details.errors || details.errors.length === 0) && - (isLocalAgent || - !queryVariables || - Object.keys(queryVariables).length === 0) && ( + (!queryVariables || Object.keys(queryVariables).length === 0) && (
{t('monitoring.messageDetails.noData')}
diff --git a/web/src/app/home/pipelines/PipelineDetailContent.tsx b/web/src/app/home/pipelines/PipelineDetailContent.tsx index 5faa06c50..098975357 100644 --- a/web/src/app/home/pipelines/PipelineDetailContent.tsx +++ b/web/src/app/home/pipelines/PipelineDetailContent.tsx @@ -35,6 +35,7 @@ export default function PipelineDetailContent({ const [activeTab, setActiveTab] = useState('config'); const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); const [formDirty, setFormDirty] = useState(false); + const [formSaving, setFormSaving] = useState(false); function handleFinish() { refreshPipelines(); @@ -53,7 +54,7 @@ export default function PipelineDetailContent({

{t('pipelines.createPipeline')}

-
@@ -68,6 +69,7 @@ export default function PipelineDetailContent({ onFinish={handleFinish} onNewPipelineCreated={handleNewPipelineCreated} onDeletePipeline={() => {}} + onSavingChange={setFormSaving} />
@@ -89,7 +91,7 @@ export default function PipelineDetailContent({ )} -
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 5012fd0dc..d9886504c 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -437,10 +437,6 @@ export interface SystemLimitation { max_bots: number; max_pipelines: number; max_extensions: number; - /** When non-empty, every pipeline is forced to this Box sandbox-scope - * template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope" - * selector is locked. Used by SaaS deployments. Empty = no restriction. */ - force_box_session_id_template?: string; } export interface WizardProgress { @@ -800,7 +796,10 @@ export interface ApiRespTools { } export interface ApiRespToolDetail { - tool: PluginTool; + tool: Omit & { + source: NonNullable; + source_id: string | null; + }; } // Skills diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 151865a1f..577ba5825 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -982,8 +982,14 @@ export class BackendClient extends BaseHttpClient { ); } - public getToolDetail(toolName: string): Promise { - return this.get(`/api/v1/tools/${toolName}`); + public getToolDetail( + toolName: string, + pipelineId?: string, + ): Promise { + return this.get( + `/api/v1/tools/${encodeURIComponent(toolName)}`, + pipelineId ? { pipeline_uuid: pipelineId } : undefined, + ); } public getMCPServer(serverName: string): Promise { diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index ce98965a7..3bce24175 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -25,7 +25,6 @@ import { import { httpClient } from '@/app/infra/http/HttpClient'; import { - userInfo, systemInfo, initializeUserInfo, initializeSystemInfo, @@ -880,24 +879,6 @@ export default function WizardPage() { saveProgress, ]); - // ---- Space auth redirect ---- - - const handleSpaceAuth = useCallback(async () => { - try { - const callbackUrl = `${window.location.origin}/auth/space/callback`; - const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl); - window.location.href = resp.authorize_url; - } catch (err) { - console.error('Failed to get space authorize URL', err); - toast.error(t('wizard.spaceAuthError')); - } - }, [t]); - - // ---- Check if local account ---- - // Re-evaluated after remote data fetch (when userInfo is populated) - const isLocalAccount = - !isLoading && (!userInfo || userInfo.account_type === 'local'); - // ---- Skip handler ---- const [showSkipConfirm, setShowSkipConfirm] = useState(false); const [isSkipping, setIsSkipping] = useState(false); @@ -1063,8 +1044,6 @@ export default function WizardPage() { onSelect={handleSelectRunner} onInstall={handleInstallRunner} onRetryCatalog={loadRunnerCatalog} - isLocalAccount={isLocalAccount} - onSpaceAuth={handleSpaceAuth} runnerConfigItems={selectedRunnerConfigItems} runnerConfigValues={runnerConfig} onRunnerConfigChange={setRunnerConfig} @@ -1579,8 +1558,6 @@ function StepAIEngine({ onSelect, onInstall, onRetryCatalog, - isLocalAccount, - onSpaceAuth, runnerConfigItems, runnerConfigValues, onRunnerConfigChange, @@ -1596,8 +1573,6 @@ function StepAIEngine({ onSelect: (name: string) => void; onInstall: (plugin: PluginV4) => void; onRetryCatalog: () => void; - isLocalAccount: boolean; - onSpaceAuth: () => void; runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigValues: Record; onRunnerConfigChange: (v: Record) => void; @@ -1870,29 +1845,6 @@ function StepAIEngine({ ); })} - - {/* Space promotion banner */} - {selected === 'plugin:langbot-team/LocalAgent/default' && - isLocalAccount && ( -
-
-
- -

- {t('wizard.spaceBanner.message')} -

- -
-
-
- )} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 0c1823427..986313b89 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1220,14 +1220,14 @@ const enUS = { selectSkills: 'Select Skills', noSkillsAvailable: 'No skills available', mcpServersScopeTooltip: - 'This only controls which MCP servers are bound to the pipeline. Choose exact MCP tools and resources in AI Feature > Local Agent.', + 'This only controls which MCP servers are bound to the pipeline. Choose exact MCP tools and resources in AI Feature > Agent Runner.', enableAllMCPServersTooltip: 'When enabled, all configured and enabled MCP servers become candidates for MCP tools and resources in AI Feature.', }, - localAgent: { + agentRunner: { toolsTitle: 'Tools', toolsDescription: - 'Select plugin, MCP, skill, and built-in tools available to this Local Agent.', + 'Select plugin, MCP, skill, and built-in tools available to this Agent Runner.', toolsScopeTooltip: 'MCP tools only come from MCP servers bound in Extensions. Bind another MCP server there to make its tools selectable here.', enableAllTools: 'Enable all tools', @@ -1245,7 +1245,7 @@ const enUS = { selectTools: 'Select tools', resourcesTitle: 'Resources', resourcesDescription: - 'Select MCP resources and knowledge bases available to this Local Agent.', + 'Select MCP resources and knowledge bases available to this Agent Runner.', knowledgeBases: 'Knowledge bases', mcpResources: 'MCP resources', mcpResourcesScopeTooltip: @@ -2011,11 +2011,6 @@ const enUS = { registrationTimeout: 'The extension installed, but its Runner did not register. Check the plugin runtime and retry.', }, - spaceBanner: { - message: - 'Connect to LangBot Space for free trial model credits and zero-config instant setup!', - action: 'Authorize with Space', - }, config: { botInfo: 'Bot Information', botNamePlaceholder: 'Enter bot name', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 827648369..143ee48b8 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1047,14 +1047,14 @@ const esES = { selectSkills: 'Seleccionar skills', noSkillsAvailable: 'No hay skills disponibles', mcpServersScopeTooltip: - 'Aquí solo se controla qué servidores MCP se vinculan al Pipeline. Las herramientas y recursos MCP concretos se eligen en AI Feature > Local Agent.', + 'Aquí solo se controla qué servidores MCP se vinculan al Pipeline. Las herramientas y recursos MCP concretos se eligen en AI Feature > Agent Runner.', enableAllMCPServersTooltip: 'Al activarlo, todos los servidores MCP configurados y habilitados serán candidatos para herramientas y recursos MCP en AI Feature.', }, - localAgent: { + agentRunner: { toolsTitle: 'Herramientas', toolsDescription: - 'Selecciona las herramientas de plugins, MCP e integradas disponibles para este Local Agent.', + 'Selecciona las herramientas de plugins, MCP e integradas disponibles para este Agent Runner.', toolsScopeTooltip: 'Las herramientas MCP solo provienen de servidores MCP vinculados en Extensiones. Vincula allí otro servidor para poder seleccionarlo aquí.', enableAllTools: 'Activar todas las herramientas', @@ -1072,7 +1072,7 @@ const esES = { selectTools: 'Seleccionar herramientas', resourcesTitle: 'Recursos', resourcesDescription: - 'Selecciona los recursos MCP y bases de conocimiento disponibles para este Local Agent.', + 'Selecciona los recursos MCP y bases de conocimiento disponibles para este Agent Runner.', knowledgeBases: 'Bases de conocimiento', mcpResources: 'Recursos MCP', mcpResourcesScopeTooltip: @@ -1717,11 +1717,6 @@ const esES = { description: 'Elige el motor de IA que impulsará la inteligencia de tu Bot.', }, - spaceBanner: { - message: - '¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!', - action: 'Autorizar con Space', - }, config: { botInfo: 'Información del Bot', botNamePlaceholder: 'Introduce el nombre del Bot', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 2390323f9..d31a227fa 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1232,14 +1232,14 @@ const jaJP = { selectSkills: 'スキルを選択', noSkillsAvailable: '利用可能なスキルがありません', mcpServersScopeTooltip: - 'ここでは、このパイプラインに紐付ける MCP サーバーだけを管理します。個別の MCP ツールとリソースは AI 機能の Local Agent で選択します。', + 'ここでは、このパイプラインに紐付ける MCP サーバーだけを管理します。個別の MCP ツールとリソースは AI 機能の Agent Runner で選択します。', enableAllMCPServersTooltip: '有効にすると、設定済みで有効なすべての MCP サーバーが AI 機能の MCP ツールとリソース候補になります。', }, - localAgent: { + agentRunner: { toolsTitle: 'ツール', toolsDescription: - 'この Local Agent が使用できるプラグイン、MCP、組み込みツールを選択します。', + 'この Agent Runner が使用できるプラグイン、MCP、組み込みツールを選択します。', toolsScopeTooltip: 'MCP ツールは拡張機能で紐付けられた MCP サーバーからのみ表示されます。追加するには先に拡張機能でサーバーを紐付けてください。', enableAllTools: 'すべてのツールを有効化', @@ -1257,7 +1257,7 @@ const jaJP = { selectTools: 'ツールを選択', resourcesTitle: 'リソース', resourcesDescription: - 'この Local Agent が読み取れる MCP リソースとナレッジベースを選択します。', + 'この Agent Runner が読み取れる MCP リソースとナレッジベースを選択します。', knowledgeBases: 'ナレッジベース', mcpResources: 'MCP リソース', mcpResourcesScopeTooltip: @@ -1934,11 +1934,6 @@ const jaJP = { registrationTimeout: '拡張機能はインストールされましたが、Runner が登録されませんでした。プラグインランタイムを確認して再試行してください。', }, - spaceBanner: { - message: - 'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!', - action: 'Spaceで認証', - }, config: { botInfo: 'ボット情報', botNamePlaceholder: 'ボット名を入力', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index b43cfc2f3..afb5bcc77 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1038,14 +1038,14 @@ const ruRU = { selectSkills: 'Выбрать навыки', noSkillsAvailable: 'Нет доступных навыков', mcpServersScopeTooltip: - 'Здесь задаётся только привязка MCP-серверов к конвейеру. Конкретные MCP-инструменты и ресурсы выбираются в AI Feature > Local Agent.', + 'Здесь задаётся только привязка MCP-серверов к конвейеру. Конкретные MCP-инструменты и ресурсы выбираются в AI Feature > Agent Runner.', enableAllMCPServersTooltip: 'Если включено, все настроенные и включённые MCP-серверы станут кандидатами для инструментов и ресурсов MCP в AI Feature.', }, - localAgent: { + agentRunner: { toolsTitle: 'Инструменты', toolsDescription: - 'Выберите инструменты плагинов, MCP и встроенные инструменты для этого Local Agent.', + 'Выберите инструменты плагинов, MCP и встроенные инструменты для этого Agent Runner.', toolsScopeTooltip: 'MCP-инструменты берутся только из MCP-серверов, привязанных в Расширениях. Чтобы добавить источник, сначала привяжите там сервер.', enableAllTools: 'Включить все инструменты', @@ -1063,7 +1063,7 @@ const ruRU = { selectTools: 'Выбрать инструменты', resourcesTitle: 'Ресурсы', resourcesDescription: - 'Выберите MCP-ресурсы и базы знаний для этого Local Agent.', + 'Выберите MCP-ресурсы и базы знаний для этого Agent Runner.', knowledgeBases: 'Базы знаний', mcpResources: 'MCP-ресурсы', mcpResourcesScopeTooltip: @@ -1687,11 +1687,6 @@ const ruRU = { description: 'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.', }, - spaceBanner: { - message: - 'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!', - action: 'Авторизация через Space', - }, config: { botInfo: 'Информация о боте', botNamePlaceholder: 'Введите имя бота', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 07e008071..15e11c4ca 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1013,14 +1013,14 @@ const thTH = { selectSkills: 'เลือกสกิล', noSkillsAvailable: 'ไม่มีสกิลที่พร้อมใช้งาน', mcpServersScopeTooltip: - 'ส่วนนี้ใช้ควบคุมว่า Pipeline ผูกกับเซิร์ฟเวอร์ MCP ใดเท่านั้น ส่วนเครื่องมือและทรัพยากร MCP รายตัวให้เลือกใน AI Feature > Local Agent', + 'ส่วนนี้ใช้ควบคุมว่า Pipeline ผูกกับเซิร์ฟเวอร์ MCP ใดเท่านั้น ส่วนเครื่องมือและทรัพยากร MCP รายตัวให้เลือกใน AI Feature > Agent Runner', enableAllMCPServersTooltip: 'เมื่อเปิดใช้ เซิร์ฟเวอร์ MCP ที่ตั้งค่าและเปิดใช้งานทั้งหมดจะเป็นตัวเลือกสำหรับเครื่องมือและทรัพยากร MCP ใน AI Feature', }, - localAgent: { + agentRunner: { toolsTitle: 'เครื่องมือ', toolsDescription: - 'เลือกเครื่องมือจากปลั๊กอิน MCP และเครื่องมือในตัวสำหรับ Local Agent นี้', + 'เลือกเครื่องมือจากปลั๊กอิน MCP และเครื่องมือในตัวสำหรับ Agent Runner นี้', toolsScopeTooltip: 'เครื่องมือ MCP จะแสดงจากเซิร์ฟเวอร์ MCP ที่ผูกไว้ในส่วนขยายเท่านั้น หากต้องการเพิ่มแหล่งเครื่องมือ ให้ไปผูกเซิร์ฟเวอร์ที่นั่นก่อน', enableAllTools: 'เปิดใช้เครื่องมือทั้งหมด', @@ -1038,7 +1038,7 @@ const thTH = { selectTools: 'เลือกเครื่องมือ', resourcesTitle: 'ทรัพยากร', resourcesDescription: - 'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Local Agent นี้', + 'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Agent Runner นี้', knowledgeBases: 'คลังความรู้', mcpResources: 'ทรัพยากร MCP', mcpResourcesScopeTooltip: @@ -1652,11 +1652,6 @@ const thTH = { title: 'เลือกเครื่องมือ AI', description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot', }, - spaceBanner: { - message: - 'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!', - action: 'ยืนยันสิทธิ์กับ Space', - }, config: { botInfo: 'ข้อมูล Bot', botNamePlaceholder: 'กรอกชื่อ Bot', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 0b579d2ae..150d46a10 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1030,14 +1030,14 @@ const viVN = { selectSkills: 'Chọn kỹ năng', noSkillsAvailable: 'Không có kỹ năng khả dụng', mcpServersScopeTooltip: - 'Tại đây chỉ kiểm soát máy chủ MCP được liên kết với Pipeline. Công cụ và tài nguyên MCP cụ thể được chọn trong AI Feature > Local Agent.', + 'Tại đây chỉ kiểm soát máy chủ MCP được liên kết với Pipeline. Công cụ và tài nguyên MCP cụ thể được chọn trong AI Feature > Agent Runner.', enableAllMCPServersTooltip: 'Khi bật, mọi máy chủ MCP đã cấu hình và bật sẽ trở thành ứng viên cho công cụ và tài nguyên MCP trong AI Feature.', }, - localAgent: { + agentRunner: { toolsTitle: 'Công cụ', toolsDescription: - 'Chọn công cụ plugin, MCP và công cụ tích hợp sẵn cho Local Agent này.', + 'Chọn công cụ plugin, MCP và công cụ tích hợp sẵn cho Agent Runner này.', toolsScopeTooltip: 'Công cụ MCP chỉ đến từ máy chủ MCP đã liên kết trong Tiện ích mở rộng. Hãy liên kết máy chủ tại đó trước nếu muốn chọn thêm tại đây.', enableAllTools: 'Bật tất cả công cụ', @@ -1055,7 +1055,7 @@ const viVN = { selectTools: 'Chọn công cụ', resourcesTitle: 'Tài nguyên', resourcesDescription: - 'Chọn tài nguyên MCP và kho tri thức cho Local Agent này.', + 'Chọn tài nguyên MCP và kho tri thức cho Agent Runner này.', knowledgeBases: 'Kho tri thức', mcpResources: 'Tài nguyên MCP', mcpResourcesScopeTooltip: @@ -1678,11 +1678,6 @@ const viVN = { title: 'Chọn công cụ AI', description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.', }, - spaceBanner: { - message: - 'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!', - action: 'Ủy quyền với Space', - }, config: { botInfo: 'Thông tin Bot', botNamePlaceholder: 'Nhập tên Bot', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 5d42f5725..6e80dad85 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1172,10 +1172,10 @@ const zhHans = { enableAllMCPServersTooltip: '开启后,所有已配置且启用的 MCP 服务器都会进入 AI 能力里的 MCP 工具和资源候选范围。', }, - localAgent: { + agentRunner: { toolsTitle: '工具', toolsDescription: - '选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。', + '选择当前 AgentRunner 可以调用的插件、MCP、技能和内置工具。', toolsScopeTooltip: 'MCP 工具只会从扩展集成中已绑定的 MCP 服务器里出现;如需增加 MCP 工具来源,请先到扩展集成绑定对应服务器。', enableAllTools: '启用所有工具', @@ -1192,7 +1192,8 @@ const zhHans = { '技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。', selectTools: '选择工具', resourcesTitle: '资源', - resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。', + resourcesDescription: + '选择当前 AgentRunner 可以读取的 MCP 资源和知识库。', knowledgeBases: '知识库', mcpResources: 'MCP 资源', mcpResourcesScopeTooltip: @@ -1920,10 +1921,6 @@ const zhHans = { registrationTimeout: '扩展已安装,但运行器未完成注册。请检查插件运行时后重试。', }, - spaceBanner: { - message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!', - action: '前往授权登录', - }, config: { botInfo: '机器人信息', botNamePlaceholder: '请输入机器人名称', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 11e0746a1..2f3a35d07 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -986,7 +986,7 @@ const zhHant = { enableAllMCPServersTooltip: '啟用後,所有已配置且啟用的 MCP 伺服器都會進入 AI 能力中的 MCP 工具和資源候選範圍。', }, - localAgent: { + agentRunner: { toolsTitle: '工具', toolsDescription: '選擇此內建 Agent 可以調用的插件、MCP 和內建工具。', toolsScopeTooltip: @@ -1603,10 +1603,6 @@ const zhHant = { title: '選擇 AI 引擎', description: '選擇驅動機器人智慧的 AI 引擎。', }, - spaceBanner: { - message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!', - action: '前往授權登入', - }, config: { botInfo: '機器人資訊', botNamePlaceholder: '請輸入機器人名稱', diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index 863c8525e..272cd2591 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -19,6 +19,52 @@ async function confirmDelete(page: Page) { .click(); } +async function installDelayedFirstSave(page: Page, apiPath: string) { + const payloads: Record[] = []; + let releaseFirstSave = () => {}; + const firstSaveGate = new Promise((resolve) => { + releaseFirstSave = resolve; + }); + + await page.route(`**${apiPath}`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.fallback(); + return; + } + + payloads.push( + JSON.parse(route.request().postData() || '{}') as Record, + ); + if (payloads.length === 1) { + await firstSaveGate; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + message: 'ok', + data: {}, + timestamp: Date.now(), + }), + }); + }); + + return { payloads, releaseFirstSave }; +} + +async function forceFormSubmit(page: Page, formSelector: string) { + await page.locator(formSelector).evaluate((form) => { + (form as HTMLFormElement).requestSubmit(); + }); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); +} + test.describe('frontend CRUD smoke flows', () => { test('creates, edits, and deletes a bot', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); @@ -56,16 +102,17 @@ test.describe('frontend CRUD smoke flows', () => { test('creates, edits, and deletes a pipeline', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); - await page.goto('/home/pipelines?id=new'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); - await expect(page.locator('input[name="basic.name"]')).toBeVisible(); - await page.locator('input[name="basic.name"]').fill('Escalation Pipeline'); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await page.locator('input[name="name"]').fill('Escalation Pipeline'); await page - .locator('input[name="basic.description"]') + .locator('input[name="description"]') .fill('Routes urgent customer issues.'); await submit(page); - await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/); + await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/); await page.reload(); await expect(page.locator('input[name="basic.name"]')).toHaveValue( 'Escalation Pipeline', @@ -82,9 +129,9 @@ test.describe('frontend CRUD smoke flows', () => { await page.getByRole('button', { name: /^Delete$/ }).click(); await confirmDelete(page); - await expect(page).toHaveURL(/\/home\/pipelines$/); + await expect(page).toHaveURL(/\/home\/agents$/); await expect( - page.getByText('Select a pipeline from the sidebar'), + page.getByText('Select an Agent or Pipeline from the sidebar'), ).toBeVisible(); }); @@ -93,7 +140,7 @@ test.describe('frontend CRUD smoke flows', () => { }) => { await installLangBotApiMocks(page, { authenticated: true }); - await page.goto('/home/pipelines?id=pipeline-ai'); + await page.goto('/home/agents?id=pipeline-ai'); await expect(page.locator('input[name="basic.name"]')).toBeVisible(); await page.getByRole('button', { name: /^AI$/ }).click(); @@ -101,7 +148,7 @@ test.describe('frontend CRUD smoke flows', () => { await expect(page.getByText('Runtime')).toBeVisible(); await expect( page.locator('[data-slot="card-title"]').filter({ - hasText: 'Built-in Agent', + hasText: 'Local Agent', }), ).toBeVisible(); await expect( @@ -330,12 +377,48 @@ test.describe('bot advanced flows', () => { }); test.describe('pipeline advanced flows', () => { + test('scopes runner tool catalogs to the edited pipeline', async ({ + page, + }) => { + await installLangBotApiMocks(page, { + authenticated: true, + withRunnerToolSelector: true, + }); + const toolCatalogUrls: URL[] = []; + const requestedPaths: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url); + requestedPaths.push(url.pathname); + }); + + await page.goto('/home/agents?id=pipeline-scope'); + await page.getByRole('button', { name: /^AI$/ }).click(); + await expect( + page.getByRole('button', { name: 'Edit tools' }), + ).toBeVisible(); + + await expect + .poll(() => + toolCatalogUrls.some( + (url) => url.searchParams.get('pipeline_uuid') === 'pipeline-scope', + ), + ) + .toBe(true); + await expect + .poll(() => + requestedPaths.includes('/api/v1/pipelines/pipeline-scope/extensions'), + ) + .toBe(true); + }); + test('switches to monitoring tab from pipeline detail', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); // Create a pipeline - await page.goto('/home/pipelines?id=new'); - await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); + await page.locator('input[name="name"]').fill('Tab Test Pipeline'); await submit(page); // Verify we're on the Configuration tab @@ -360,8 +443,9 @@ test.describe('pipeline advanced flows', () => { await installLangBotApiMocks(page, { authenticated: true }); // Create a pipeline - await page.goto('/home/pipelines?id=new'); - await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); + await page.locator('input[name="name"]').fill('Dirty Form Pipeline'); await submit(page); // Wait for the page to fully load and form to reset @@ -385,14 +469,153 @@ test.describe('pipeline advanced flows', () => { }) => { await installLangBotApiMocks(page, { authenticated: true }); - await page.goto('/home/pipelines?id=new'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); // Submit without filling name await submit(page); // Should show validation error for name (zod validation) await expect(page.getByText(/cannot be empty/i)).toBeVisible(); - await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/); + await expect(page).toHaveURL(/\/home\/agents\?id=new$/); + }); +}); + +test.describe('agent runner resource selectors', () => { + test('uses the global catalog and preserves temporarily unavailable tools', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const toolCatalogUrls: URL[] = []; + const requestedPaths: string[] = []; + let savedAgent: Record | undefined; + page.on('request', (request) => { + const url = new URL(request.url()); + requestedPaths.push(url.pathname); + if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url); + if ( + url.pathname === '/api/v1/agents/agent-scope' && + request.method() === 'PUT' + ) { + savedAgent = JSON.parse(request.postData() || '{}') as Record< + string, + unknown + >; + } + }); + + await page.goto('/home/agents?id=agent-scope'); + await page.getByRole('button', { name: /^Runner$/ }).click(); + await page.getByRole('button', { name: 'Edit tools' }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('available_plugin_tool')).toBeVisible(); + await dialog + .getByRole('checkbox', { name: 'Select available_plugin_tool' }) + .click(); + await dialog.getByRole('button', { name: /^Confirm$/ }).click(); + await save(page); + + await expect.poll(() => savedAgent).toBeTruthy(); + expect(savedAgent).toMatchObject({ + config: { + runner_config: { + 'plugin:langbot-team/LocalAgent/default': { + tools: ['unavailable_plugin_tool', 'available_plugin_tool'], + }, + }, + }, + }); + expect( + toolCatalogUrls.some((url) => url.searchParams.has('pipeline_uuid')), + ).toBe(false); + expect(requestedPaths).not.toContain( + '/api/v1/pipelines/agent-scope/extensions', + ); + }); +}); + +test.describe('agent and pipeline save concurrency', () => { + test('agent save freezes its payload and keeps later edits dirty', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const delayedSave = await installDelayedFirstSave( + page, + '/api/v1/agents/agent-save-race', + ); + + await page.goto('/home/agents?id=agent-save-race'); + const saveButton = page.getByRole('button', { name: /^Save$/ }); + const nameInput = page.locator('input[name="basic.name"]'); + const descriptionInput = page.locator('input[name="basic.description"]'); + await expect(nameInput).toBeVisible(); + + await nameInput.fill('Submitted Agent'); + await saveButton.click(); + await expect.poll(() => delayedSave.payloads.length).toBe(1); + await expect(saveButton).toBeDisabled(); + + await descriptionInput.fill('Edited while the agent save is pending'); + await forceFormSubmit(page, '#agent-form'); + expect(delayedSave.payloads).toHaveLength(1); + await expect(saveButton).toBeDisabled(); + + delayedSave.releaseFirstSave(); + await expect(saveButton).toBeEnabled(); + expect(delayedSave.payloads[0]).toMatchObject({ + name: 'Submitted Agent', + description: '', + }); + + await saveButton.click(); + await expect.poll(() => delayedSave.payloads.length).toBe(2); + expect(delayedSave.payloads[1]).toMatchObject({ + name: 'Submitted Agent', + description: 'Edited while the agent save is pending', + }); + await expect(saveButton).toBeDisabled(); + }); + + test('pipeline save freezes its payload and keeps later edits dirty', async ({ + page, + }) => { + await installLangBotApiMocks(page, { authenticated: true }); + const delayedSave = await installDelayedFirstSave( + page, + '/api/v1/pipelines/pipeline-save-race', + ); + + await page.goto('/home/agents?id=pipeline-save-race'); + const saveButton = page.getByRole('button', { name: /^Save$/ }); + const nameInput = page.locator('input[name="basic.name"]'); + const descriptionInput = page.locator('input[name="basic.description"]'); + await expect(nameInput).toBeVisible(); + + await nameInput.fill('Submitted Pipeline'); + await saveButton.click(); + await expect.poll(() => delayedSave.payloads.length).toBe(1); + await expect(saveButton).toBeDisabled(); + + await descriptionInput.fill('Edited while the pipeline save is pending'); + await forceFormSubmit(page, '#pipeline-form'); + expect(delayedSave.payloads).toHaveLength(1); + await expect(saveButton).toBeDisabled(); + + delayedSave.releaseFirstSave(); + await expect(saveButton).toBeEnabled(); + expect(delayedSave.payloads[0]).toMatchObject({ + name: 'Submitted Pipeline', + description: '', + }); + + await saveButton.click(); + await expect.poll(() => delayedSave.payloads.length).toBe(2); + expect(delayedSave.payloads[1]).toMatchObject({ + name: 'Submitted Pipeline', + description: 'Edited while the pipeline save is pending', + }); + await expect(saveButton).toBeDisabled(); }); }); @@ -401,10 +624,11 @@ test.describe('cross-resource flows', () => { await installLangBotApiMocks(page, { authenticated: true }); // Create a pipeline first - await page.goto('/home/pipelines?id=new'); - await page.locator('input[name="basic.name"]').fill('Production Pipeline'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); + await page.locator('input[name="name"]').fill('Production Pipeline'); await submit(page); - await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/); + await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/); // Create a bot await page.goto('/home/bots?id=new'); @@ -417,17 +641,15 @@ test.describe('cross-resource flows', () => { // Wait for form to fully load await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot'); - // Find the pipeline select by its label "Bind Pipeline" - const pipelineCard = page.getByText('Bind Pipeline').locator('..'); - await expect(pipelineCard).toBeVisible({ timeout: 5000 }); - - // Click on the select trigger within the pipeline binding card - // The select trigger shows "Select Pipeline" placeholder initially - const pipelineSelectTrigger = page.getByText('Select Pipeline').first(); - await pipelineSelectTrigger.click(); + await page.getByRole('button', { name: 'Add behavior' }).click(); + await page.getByRole('menuitem', { name: /^Reply to messages/ }).click(); + await page + .getByRole('combobox') + .filter({ hasText: 'Select processor' }) + .click(); // Select the pipeline option - await page.getByRole('option', { name: 'Production Pipeline' }).click(); + await page.getByRole('option', { name: /Production Pipeline/ }).click(); // Save the bot await save(page); @@ -436,9 +658,7 @@ test.describe('cross-resource flows', () => { await page.reload(); // The pipeline name should appear in the select trigger (not in sidebar or options) await expect( - page - .locator('[data-slot="select-trigger"]') - .filter({ hasText: 'Production Pipeline' }), + page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }), ).toBeVisible(); }); }); @@ -451,12 +671,12 @@ test.describe('empty states', () => { await expect(page.getByText('Select a bot from the sidebar')).toBeVisible(); }); - test('shows empty state when no pipelines exist', async ({ page }) => { + test('shows empty state when no processors exist', async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); - await page.goto('/home/pipelines'); + await page.goto('/home/agents'); await expect( - page.getByText('Select a pipeline from the sidebar'), + page.getByText('Select an Agent or Pipeline from the sidebar'), ).toBeVisible(); }); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index fda101148..5e0ed08a5 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -21,6 +21,29 @@ interface PipelineMock { updated_at: string; } +interface AgentMock { + uuid: string; + name: string; + description: string; + emoji: string; + kind: 'agent'; + component_ref: string; + config: JsonRecord; + enabled: boolean; + supported_event_patterns: string[]; + updated_at: string; +} + +interface ToolMock { + name: string; + description: string; + human_desc: string; + parameters: JsonRecord; + source: 'builtin' | 'plugin' | 'mcp' | 'skill'; + source_name?: string; + source_id?: string; +} + interface KnowledgeBaseMock { uuid: string; name: string; @@ -64,10 +87,12 @@ interface BotMock { use_pipeline_uuid?: string; pipeline_routing_rules: unknown[]; adapter_runtime_values: JsonRecord; + event_bindings: unknown[]; updated_at: string; } interface LangBotApiMockState { + agents: AgentMock[]; bots: BotMock[]; counters: Record; knowledgeBases: KnowledgeBaseMock[]; @@ -78,6 +103,8 @@ interface LangBotApiMockState { sessionAnalyses: Record; sessionMessages: Record; skills: SkillMock[]; + tools: ToolMock[]; + withRunnerToolSelector: boolean; } function ok(data: unknown) { @@ -183,7 +210,20 @@ function makePipeline( name: String(data.name || ''), description: String(data.description || ''), config: (data.config as JsonRecord | undefined) || { - ai: {}, + ai: { + runner: { + id: 'plugin:langbot-team/LocalAgent/default', + 'expire-time': 0, + }, + runner_config: { + 'plugin:langbot-team/LocalAgent/default': { + model: { + primary: 'llm-valid', + fallbacks: [], + }, + }, + }, + }, trigger: {}, safety: {}, output: {}, @@ -194,7 +234,41 @@ function makePipeline( }; } -function pipelineMetadata() { +function makeAgent(data: JsonRecord, uuid: string): AgentMock { + return { + uuid, + name: String(data.name || uuid), + description: String(data.description || ''), + emoji: String(data.emoji || '🤖'), + kind: 'agent', + component_ref: String( + data.component_ref || 'plugin:langbot-team/LocalAgent/default', + ), + config: (data.config as JsonRecord | undefined) || { + runner: { + id: 'plugin:langbot-team/LocalAgent/default', + 'expire-time': 0, + }, + runner_config: { + 'plugin:langbot-team/LocalAgent/default': { + model: { + primary: 'llm-valid', + fallbacks: [], + }, + 'enable-all-tools': false, + tools: ['unavailable_plugin_tool'], + }, + }, + }, + enabled: data.enabled !== false, + supported_event_patterns: (data.supported_event_patterns as + | string[] + | undefined) || ['*'], + updated_at: now(), + }; +} + +function pipelineMetadata(withRunnerToolSelector = false) { return { configs: [ { @@ -212,21 +286,21 @@ function pipelineMetadata() { }, config: [ { - id: 'runner', - name: 'runner', + id: 'runner.id', + name: 'id', label: { en_US: 'Runner', zh_Hans: '运行器', }, type: 'select', required: true, - default: 'local-agent', + default: 'plugin:langbot-team/LocalAgent/default', options: [ { - name: 'local-agent', + name: 'plugin:langbot-team/LocalAgent/default', label: { - en_US: 'Built-in Agent', - zh_Hans: '内置 Agent', + en_US: 'Local Agent', + zh_Hans: '本地 Agent', }, }, ], @@ -234,14 +308,14 @@ function pipelineMetadata() { ], }, { - name: 'local-agent', + name: 'plugin:langbot-team/LocalAgent/default', label: { - en_US: 'Built-in Agent', - zh_Hans: '内置 Agent', + en_US: 'Local Agent', + zh_Hans: '本地 Agent', }, config: [ { - id: 'model', + id: 'plugin:langbot-team/LocalAgent/default.model', name: 'model', label: { en_US: 'Model', @@ -254,6 +328,21 @@ function pipelineMetadata() { fallbacks: [], }, }, + ...(withRunnerToolSelector + ? [ + { + id: 'plugin:langbot-team/LocalAgent/default.tools', + name: 'tools', + label: { + en_US: 'Tools', + zh_Hans: '工具', + }, + type: 'rich-tools-selector', + required: false, + default: [], + }, + ] + : []), ], }, ], @@ -262,6 +351,19 @@ function pipelineMetadata() { }; } +function agentMetadata() { + return { + runner_config: pipelineMetadata(true).configs[0], + kinds: [ + { + name: 'agent', + supported_event_patterns: ['*'], + message_only: false, + }, + ], + }; +} + function providerModelList() { return { models: [ @@ -366,6 +468,7 @@ function makeBot( : undefined, pipeline_routing_rules: (data.pipeline_routing_rules as unknown[] | undefined) || [], + event_bindings: (data.event_bindings as unknown[] | undefined) || [], adapter_runtime_values: { webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`, extra_webhook_full_url: '', @@ -503,8 +606,90 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { return fulfillJson(route, { models: [] }); } + if (path === '/api/v1/agents/_/metadata') { + return fulfillJson(route, agentMetadata()); + } + + if (path === '/api/v1/agents') { + if (method === 'POST') { + const data = parseJsonBody(route); + if (data.kind === 'pipeline') { + const pipeline = makePipeline(state, data); + state.pipelines = [ + ...state.pipelines.filter((item) => item.uuid !== pipeline.uuid), + pipeline, + ]; + return fulfillJson(route, { uuid: pipeline.uuid, kind: 'pipeline' }); + } + + const agentId = nextId(state, 'agent'); + const agent = makeAgent(data, agentId); + state.agents = [ + ...state.agents.filter((item) => item.uuid !== agentId), + agent, + ]; + return fulfillJson(route, { uuid: agentId, kind: 'agent' }); + } + + return fulfillJson(route, { + agents: [ + ...state.agents, + ...state.pipelines.map((pipeline) => ({ + ...pipeline, + kind: 'pipeline', + component_ref: 'pipeline', + enabled: true, + supported_event_patterns: ['message.*'], + })), + ], + }); + } + + const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/); + if (agentMatch) { + const agentId = decodeURIComponent(agentMatch[1]); + + if (method === 'PUT') { + const agent = makeAgent(parseJsonBody(route), agentId); + state.agents = [ + ...state.agents.filter((item) => item.uuid !== agentId), + agent, + ]; + return fulfillJson(route, {}); + } + + if (method === 'DELETE') { + state.agents = state.agents.filter((item) => item.uuid !== agentId); + return fulfillJson(route, {}); + } + + if (agentId.startsWith('pipeline-')) { + let pipeline = state.pipelines.find((item) => item.uuid === agentId); + if (!pipeline) { + pipeline = makePipeline(state, { name: agentId }, agentId); + state.pipelines = [...state.pipelines, pipeline]; + } + return fulfillJson(route, { + agent: { + ...pipeline, + kind: 'pipeline', + component_ref: 'pipeline', + enabled: true, + supported_event_patterns: ['message.*'], + }, + }); + } + + let agent = state.agents.find((item) => item.uuid === agentId); + if (!agent) { + agent = makeAgent({ name: agentId }, agentId); + state.agents = [...state.agents, agent]; + } + return fulfillJson(route, { agent }); + } + if (path === '/api/v1/pipelines/_/metadata') { - return fulfillJson(route, pipelineMetadata()); + return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector)); } if (path === '/api/v1/pipelines') { @@ -559,6 +744,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { available_plugins: [], bound_mcp_servers: [], available_mcp_servers: state.mcpServers, + bound_mcp_resources: [], + mcp_resource_agent_read_enabled: true, bound_skills: [], available_skills: state.skills, }); @@ -624,6 +811,10 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { }); } + if (path === '/api/v1/tools') { + return fulfillJson(route, { tools: state.tools }); + } + if (path === '/api/v1/plugins') { return fulfillJson(route, { plugins: [] }); } @@ -951,6 +1142,7 @@ export async function installLangBotApiMocks( sessionAnalyses?: Record; sessionMessages?: Record; storage?: JsonRecord; + withRunnerToolSelector?: boolean; } = {}, ) { const { @@ -960,8 +1152,10 @@ export async function installLangBotApiMocks( sessionAnalyses, sessionMessages, storage = {}, + withRunnerToolSelector = false, } = options; const state: LangBotApiMockState = { + agents: [], bots: [], counters: {}, knowledgeBases: [], @@ -972,6 +1166,18 @@ export async function installLangBotApiMocks( sessionAnalyses: sessionAnalyses || {}, sessionMessages: sessionMessages || {}, skills: [], + tools: [ + { + name: 'available_plugin_tool', + description: 'Available plugin tool', + human_desc: 'Available plugin tool', + parameters: {}, + source: 'plugin', + source_name: 'langbot-app/TestTools', + source_id: 'langbot-app/TestTools', + }, + ], + withRunnerToolSelector, }; await page.addInitScript(