From 51f0e2de1cf7d402adb32ccefb99210f9857ba64 Mon Sep 17 00:00:00 2001 From: derrickLzw <78075015+DerrickJc@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:46:16 +0800 Subject: [PATCH] feat(extensions): scope configured middlewares to lead or subagent (#7661) * feat(extensions): scope configured middlewares to lead or subagent * fix(helm): synchronize configured middleware config schema version * fix(extensions): require explicit configured middleware scope --- README.md | 2 +- README_ko.md | 2 +- .../deerflow/agents/lead_agent/agent.py | 2 +- .../deerflow/agents/middlewares/AGENTS.md | 9 +- .../middlewares/configured_extensions.py | 31 ++-- .../tool_error_handling_middleware.py | 2 +- .../harness/deerflow/config/AGENTS.md | 2 +- .../deerflow/config/extensions_config.py | 6 +- backend/tests/test_app_config_reload.py | 24 ++++ backend/tests/test_configured_extensions.py | 17 +-- .../tests/test_configured_middleware_scope.py | 136 ++++++++++++++++++ config.example.yaml | 1 + .../src/content/en/harness/customization.mdx | 5 +- .../src/content/en/harness/middlewares.mdx | 5 +- .../src/content/zh/harness/customization.mdx | 5 +- .../src/content/zh/harness/middlewares.mdx | 5 +- 16 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 backend/tests/test_configured_middleware_scope.py diff --git a/README.md b/README.md index a8a2ffac4b..409c5a9ff5 100644 --- a/README.md +++ b/README.md @@ -2225,7 +2225,7 @@ Follow-up suggestions also check `model:use` before calling the selected model, For vision-capable agents, `view_image` and the subsequent model-context image read also require `sandbox:execute`. Allowing the tool name alone does not grant image-file access; a role denied sandbox execution cannot reread previously recorded image metadata after its permissions change. External run input and thread-state updates cannot set `viewed_images` or `thread_data`; when a saved image is read from a host copy, its path must resolve to the current user's thread and the recorded virtual image path. Each model request includes at most four recently viewed images and 28 MiB of base64 image data. Images excluded by these limits are reported as omitted; the agent can call `view_image` again to bring one back into context. -Advanced deployments can also extend the agent runtime itself by declaring `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. Each entry is a `module.path:ClassName` string (zero-argument constructor) or an object `{class, kwargs}` whose `kwargs` are passed to the constructor. `kwargs` values must be JSON types (object, array, string, number, boolean, or null); YAML dates and timestamps are coerced to ISO strings so they match JSON. DeerFlow loads the same configured list into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, broken modules, and constructor errors fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Separate lead-only/subagent-only middleware lists are not supported yet. +Advanced deployments can also extend the agent runtime itself by declaring `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. Each entry is a `module.path:ClassName` string (zero-argument constructor) or an object `{class, kwargs, apply_to}` whose `kwargs` are passed to the constructor. Optional `apply_to` selects `both` (default), `lead`, or `subagent`; legacy strings still apply to both chains. `kwargs` values must be JSON types (object, array, string, number, boolean, or null); YAML dates and timestamps are coerced to ISO strings so they match JSON. DeerFlow loads the selected configured entries into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, broken modules, and constructor errors fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Entries excluded from a chain are not imported or constructed there. Invalid scope values fail configuration validation, and selected entries retain their relative order. Packaged extensions continue to use their existing `AgentScope` contract. For packaged and configurable runtime integrations, use DeerFlow's extension manager. It accepts a Python package requirement, a public HTTPS Git URL, or a local directory, installs the diff --git a/README_ko.md b/README_ko.md index 06acbbb342..528178d376 100644 --- a/README_ko.md +++ b/README_ko.md @@ -1195,7 +1195,7 @@ Agent settings에서 **Tool access**는 설정된 모든 도구 그룹을 상속 비전을 지원하는 에이전트의 경우 `view_image`와 그 뒤에 이어지는 모델 컨텍스트 이미지 읽기에도 `sandbox:execute`가 필요합니다. 도구 이름만 허용해서는 이미지 파일 접근 권한이 부여되지 않으며, 샌드박스 실행이 거부된 역할은 권한이 변경된 후 이전에 기록된 이미지 메타데이터를 다시 읽을 수 없습니다. 외부 실행 입력과 스레드 상태 업데이트로는 `viewed_images`나 `thread_data`를 설정할 수 없습니다. 저장된 이미지를 호스트 사본에서 읽을 때는 해당 경로가 현재 사용자의 스레드와 기록된 가상 이미지 경로로 해석되어야 합니다. 각 모델 요청에는 최근에 본 이미지가 최대 4개, base64 이미지 데이터가 최대 28 MiB까지 포함됩니다. 이 제한으로 제외된 이미지는 생략된 것으로 보고되며, 에이전트는 `view_image`를 다시 호출해 이미지를 컨텍스트로 다시 가져올 수 있습니다. -고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 설정된 같은 목록을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 리드 에이전트 전용/서브에이전트 전용 미들웨어 목록을 따로 지정하는 기능은 아직 지원되지 않습니다. +고급 배포에서는 `config.yaml` 또는 `extensions_config.json`의 `extensions.middlewares` 아래에 `AgentMiddleware` 클래스를 선언하여 에이전트 런타임 자체를 확장할 수도 있습니다. 각 항목은 `module.path:ClassName` 문자열(인자 없는 생성자) 또는 객체 `{class, kwargs, apply_to}`이며, 객체의 `kwargs`는 생성자에 전달됩니다. `kwargs` 값은 JSON 타입(객체, 배열, 문자열, 숫자, 불리언, null)이어야 하며, YAML 날짜와 타임스탬프는 JSON과 일치하도록 ISO 문자열로 변환됩니다. DeerFlow는 각 체인에 대해 선택된 설정 항목을 리드 에이전트와 서브에이전트 파이프라인에 불러오며, 그 위치는 기본 제공 런타임 미들웨어와 루프/토큰 가드 뒤, 그리고 terminal-response/safety/clarification 꼬리 앞입니다. 따라서 엔터프라이즈 포크는 기본 제공 미들웨어 빌더를 패치하지 않고도 도메인 가드레일, 도구 호출 거버넌스, 관측성 훅을 추가할 수 있습니다. 패키지 누락, 잘못된 클래스, 손상된 모듈, 생성자 오류는 에이전트 생성 시점에 명확한 오류로 실패합니다. `config.yaml`과 `extensions_config.json`은 운영자가 제어하는 신뢰된 파일로 취급하세요. 커스텀 도구, 모델, 샌드박스, 가드레일, MCP 서버, MCP 인터셉터 선언과 마찬가지로 미들웨어 경로는 곧 코드 실행입니다. Gateway의 스킬/MCP 토글 엔드포인트는 이 필드를 보존하지만 `extensions.middlewares`에 대한 API 쓰기 경로는 제공하지 않습니다. 객체의 선택적 `apply_to`는 `both`(기본값), `lead`, `subagent`를 지원하며, 문자열 항목과 이 필드를 생략한 객체는 계속 두 파이프라인 모두에 적용됩니다. 해당 체인에서 제외된 항목은 임포트하거나 인스턴스화하지 않습니다. 패키지화되고 설정 가능한 런타임 통합에는 DeerFlow의 확장 관리자를 사용하세요. 확장 관리자는 Python 패키지 요구 사항, 공개 HTTPS Git URL, 또는 로컬 디렉터리를 받아 백엔드 전용 `extensions` 의존성 그룹에 패키지를 설치하고, `backend/uv.lock`을 업데이트하며, 시작 시에만 읽는 `config.yaml`의 최상위 `plugins:` 목록에 활성화된 항목을 추가합니다. diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 582bebd32c..f37c2fc3fe 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -750,7 +750,7 @@ def build_middlewares( if custom_middlewares: middlewares.extend(custom_middlewares) - configured_middlewares = load_configured_extension_middlewares(resolved_app_config) + configured_middlewares = load_configured_extension_middlewares(resolved_app_config, scope="lead") if configured_middlewares: middlewares.extend(configured_middlewares) diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 10cd14bead..5d47434369 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -38,6 +38,13 @@ stay `in_progress`. Never infer status from reply text. Assembly order: `tool_error_handling_middleware.py::_build_runtime_middlewares` (exposed as `build_lead_runtime_middlewares`), then `../lead_agent/agent.py::build_middlewares`. Optional entries require their config/runtime condition. +Config-declared middleware uses `configured_extensions.py`; the lead and subagent +builders pass their own required keyword-only `scope` (no implicit lead default). +Optional `apply_to: both | lead | subagent` +defaults to both, including legacy strings. Filter before reflection/construction; +preserve selected entry order and create fresh instances per build. This fixed-slot +selector does not alter packaged extensions' `AgentScope` or placement contract. + **Message provenance.** Injected/rewritten messages use `deerflow_extension_api.provenance.provenance_kwargs()`: server-owned `deerflow_content_kind`, `deerflow_producer_kind`, optional entity ID, even @@ -177,7 +184,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../ 32. **TokenBudgetMiddleware** - `token_budget.enabled`: run-ID budgets survive continuations; missing/invalid IDs reset invocation state. With token usage tracking, place CompletedSubagentUsageMiddleware after the guard: reversed `after_model` hooks backfill terminal child usage before enforcement. Keep TokenUsageMiddleware earlier so current-step attribution reflects calls retained by all guards. 33. **ModelContentCompatibilityMiddleware** - Read-time sanitizer (`wrap_model_call`/`awrap_model_call`, request view only; never writes state): rewrites URL-sourced `file` blocks (any role, gated on the `url` key; an http(s) URL is preserved when the selected model's serializer accepts it — OpenAI Responses API or Anthropic) and non-fetchable-scheme (non-`http`/`https`/`data`) `image` blocks into text placeholders whose MIME/location metadata passes the shared `neutralize_untrusted_tags` guard, so pre-MCP-fix checkpoints heal on the next turn with no migration. Uses the conversion layer's `resource_placeholder_text`/`model_visible_location` for model-visible text; artifact capture scans state messages, so healing adds neither `resource_links` nor handles. See the MCP guide for conversion and the module docstring for healing; malformed URLs degrade to location-less placeholders instead of raising. Registered after DurableContext/summarization, before the date/coalescer tail. 34. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail -35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Subagents share the list before their safety tail; separate lead/subagent lists are unsupported. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review. +35. **Configured extension middlewares** - `extensions.middlewares` in `config.yaml` or `extensions_config.json` optionally accepts `module.path:ClassName` strings or `{class, kwargs, apply_to}` objects. `deerflow.reflection.resolve_class` loads `AgentMiddleware` classes; import, class, and constructor errors fail agent creation. `kwargs` must be JSON-compatible; YAML dates/timestamps become ISO strings. Order: built-ins/custom and loop/token guards → extensions → terminal-response/safety/clarification tail. Optional `apply_to` selects both chains (default), lead, or subagent before import/construction. Trusted operator config only: paths instantiate arbitrary code. Gateway skill/MCP toggles preserve it in raw JSON; adding an API write path requires explicit trust-boundary review. 36. **TerminalResponseMiddleware** - When a provider returns an empty terminal `AIMessage` after tool execution, injects a hidden recovery prompt and retries the model once; a second empty response is replaced in checkpoint state by a visible error fallback marked for the run worker, so the run finishes as an error instead of a silent success 37. **ModelLengthFinishReasonMiddleware** - Responses: same-container `status=incomplete` + `incomplete_details.reason=max_output_tokens`. Suppress calls via shared cloning after clearing invalid calls; keep non-call blocks/metadata. Stamp cap markers; add notice. Also registered on subagent chains (before Safety), where `consume_stop_reason` surfaces `model_length_capped` to the executor. 38. **SafetyFinishReasonMiddleware** - `safety_finish_reason.enabled`: suppresses valid/invalid safety-stopped calls. Responses: `status=incomplete` + `incomplete_details.reason=content_filter`. Clear invalid calls before shared cloning. Place after terminal/custom/configured hooks; `after_model` runs in reverse. diff --git a/backend/packages/harness/deerflow/agents/middlewares/configured_extensions.py b/backend/packages/harness/deerflow/agents/middlewares/configured_extensions.py index 0c82acaa85..cfbac53e2e 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/configured_extensions.py +++ b/backend/packages/harness/deerflow/agents/middlewares/configured_extensions.py @@ -1,7 +1,7 @@ """Config-declared agent middleware loading.""" import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from langchain.agents.middleware import AgentMiddleware @@ -14,34 +14,39 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -def _middleware_constructor_args(entry: str | ConfiguredMiddlewareSpec | dict[str, Any]) -> tuple[str, dict[str, Any]]: - """Return ``(class_path, kwargs)`` for one config entry.""" +def _middleware_spec(entry: str | ConfiguredMiddlewareSpec | dict[str, Any]) -> ConfiguredMiddlewareSpec: + """Normalize and validate a config entry before scope selection.""" if isinstance(entry, str): - return entry, {} + return ConfiguredMiddlewareSpec(class_path=entry) if isinstance(entry, ConfiguredMiddlewareSpec): - return entry.class_path, dict(entry.kwargs) - parsed = ConfiguredMiddlewareSpec.model_validate(entry) - return parsed.class_path, dict(parsed.kwargs) + return entry + return ConfiguredMiddlewareSpec.model_validate(entry) -def load_configured_extension_middlewares(app_config: "AppConfig") -> list[AgentMiddleware]: +def load_configured_extension_middlewares(app_config: "AppConfig", *, scope: Literal["lead", "subagent"]) -> list[AgentMiddleware]: """Instantiate config-declared agent middlewares. Each entry is a ``module.path:ClassName`` string or a - ``ConfiguredMiddlewareSpec`` (``class`` plus optional ``kwargs``). + ``ConfiguredMiddlewareSpec`` (``class`` plus optional ``kwargs`` and + ``apply_to``). The host must explicitly supply the concrete chain scope. + Excluded entries are not imported or constructed. Import, attribute, and subclass validation intentionally go through the shared reflection resolver so failures carry the same actionable dependency hints as models, tools, sandbox providers, and guardrail providers. Constructor errors fail loudly at agent creation. """ + if scope not in ("lead", "subagent"): + raise ValueError("middleware scope must be 'lead' or 'subagent'") middlewares: list[AgentMiddleware] = [] for entry in list(app_config.extensions.middlewares or []): - class_path, kwargs = _middleware_constructor_args(entry) - middleware_cls = resolve_class(class_path, AgentMiddleware) + spec = _middleware_spec(entry) + if spec.apply_to not in ("both", scope): + continue + middleware_cls = resolve_class(spec.class_path, AgentMiddleware) try: - middleware = middleware_cls(**kwargs) + middleware = middleware_cls(**dict(spec.kwargs)) except Exception: - logger.exception("Failed to instantiate configured extension middleware %s", class_path) + logger.exception("Failed to instantiate configured extension middleware %s", spec.class_path) raise middlewares.append(middleware) return middlewares diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index aa174b39bc..56db97c54a 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -699,7 +699,7 @@ def build_subagent_runtime_middlewares( from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares - middlewares.extend(load_configured_extension_middlewares(app_config)) + middlewares.extend(load_configured_extension_middlewares(app_config, scope="subagent")) # Same provider output-length guard the lead agent uses. A subagent answer # cut off at the model's output limit would otherwise reach the lead as a diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 27578d820e..219cb9e5f1 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -191,7 +191,7 @@ Tests: `tests/test_secret_file.py`, `tests/test_credentials_key.py`. - `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`, `session_init_timeout`). `routing.mode="prefer"` emits `` prompt guidance; if `tool_search` defers the hinted tool, `McpRoutingMiddleware` can also auto-promote matching deferred schemas before the model call. It does not hard-disable other tools. `session_init_timeout` (default `DEFAULT_MCP_SESSION_INIT_TIMEOUT` = 60s, `null` to disable) bounds server bring-up: tool discovery and persistent stdio session initialization, so a hung server cannot block agent construction indefinitely; durable HTTP/SSE task calls use it for their ephemeral session initialization too. `tool_call_timeout` bounds individual stdio calls and durable-task calls on every transport; other HTTP/SSE tools use transport-level timeouts. - `tool_search.auto_promote_top_k` - Global MCP routing auto-promote breadth. Default `3`, clamped to `1..5`; applies only when `tool_search.enabled=true` and only to deferred MCP tools with `routing.mode="prefer"` and non-empty keywords. For lead agents the deferred catalog is built from the full configured MCP set; auto-promotion never grants authority — it only raises visibility for tools already admitted to the agent's catalog, while assembly and execution authorization still apply independently. - `skills` - Map of skill name → state (enabled) -- `middlewares` - `AgentMiddleware` entries for lead and subagent runtime extension: class-path strings or `{class, kwargs}` objects. `kwargs` values must be JSON types; YAML dates and timestamps are coerced to ISO strings so they match JSON. `config.yaml -> extensions` can override these fields after validation; overrides are replace-per-field, not list concatenation. +- `middlewares` - `AgentMiddleware` entries for lead and subagent runtime extension: class-path strings or `{class, kwargs, apply_to}` objects. Optional `apply_to` is `both` (default), `lead`, or `subagent`; strings always apply to both chains. Validate all entries before building agents; excluded entries are not imported or constructed. `kwargs` values must be JSON types; YAML dates and timestamps are coerced to ISO strings so they match JSON. `config.yaml -> extensions` can override these fields after validation; overrides are replace-per-field, not list concatenation. Gateway API endpoints and `DeerFlowClient` methods can modify MCP servers and skill state at runtime; their `extensions_config.json` writes use the shared atomic replacement helper, while `middlewares` remains an operator-controlled config-file extension point. diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index 346713c07d..dff0f0a71d 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -369,6 +369,10 @@ class ConfiguredMiddlewareSpec(BaseModel): default_factory=dict, description=("Keyword arguments passed to the middleware constructor. Values must be JSON types (object, array, string, number, boolean, or null); YAML dates and timestamps are coerced to ISO strings so they match JSON."), ) + apply_to: Literal["both", "lead", "subagent"] = Field( + default="both", + description="Agent chain that loads this middleware. Omitted scope preserves both chains.", + ) model_config = ConfigDict(extra="forbid", populate_by_name=True) @field_validator("class_path") @@ -400,7 +404,7 @@ class ExtensionsConfig(BaseModel): description=( "AgentMiddleware entries loaded into the lead-agent and subagent middleware chains. " "Each entry is a 'module.path:ClassName' string or an object with 'class' and optional " - "'kwargs'. kwargs values must be JSON types; YAML dates and timestamps are coerced to " + "'kwargs' and 'apply_to' (both, lead, or subagent; default both). kwargs values must be JSON types; YAML dates and timestamps are coerced to " "ISO strings." ), ) diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index e12a07d6fd..64fe41685d 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -1107,3 +1107,27 @@ def test_get_app_config_keeps_config_yaml_extension_override_after_extensions_re assert get_app_config().extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"] finally: _reset_config_singletons() + + +def test_middleware_scope_reloads_from_extensions_and_yaml_override(tmp_path, monkeypatch): + entry = {"class": "example:Observer", "kwargs": {"label": "observer"}, "apply_to": "lead"} + extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=[entry]) + try: + assert get_app_config().extensions.middlewares[0].apply_to == "lead" + _peer_writes_extensions(extensions_path, middlewares=[{**entry, "apply_to": "subagent"}]) + assert get_app_config().extensions.middlewares[0].apply_to == "subagent" + + config_path = tmp_path / "config.yaml" + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + raw["extensions"] = {"middlewares": [{**entry, "apply_to": "both"}]} + config_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + assert get_app_config().extensions.middlewares[0].apply_to == "both" + + _peer_writes_extensions(extensions_path, middlewares=[entry]) + assert get_app_config().extensions.middlewares[0].apply_to == "both" + raw["extensions"]["middlewares"][0]["apply_to"] = "invalid" + config_path.write_text(yaml.safe_dump(raw), encoding="utf-8") + with pytest.raises(ValidationError, match="apply_to"): + AppConfig.from_file(str(config_path)) + finally: + _reset_config_singletons() diff --git a/backend/tests/test_configured_extensions.py b/backend/tests/test_configured_extensions.py index 95be61ffe7..8af9837751 100644 --- a/backend/tests/test_configured_extensions.py +++ b/backend/tests/test_configured_extensions.py @@ -37,7 +37,7 @@ def _config(*entries) -> SimpleNamespace: def test_string_entry_still_zero_arg(): - loaded = load_configured_extension_middlewares(_config(f"{__name__}:ZeroArgMiddleware")) + loaded = load_configured_extension_middlewares(_config(f"{__name__}:ZeroArgMiddleware"), scope="lead") assert len(loaded) == 1 assert isinstance(loaded[0], ZeroArgMiddleware) @@ -46,7 +46,7 @@ def test_string_entry_still_zero_arg(): def test_dict_entry_passes_constructor_kwargs(): entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:RecordingMiddleware", "kwargs": {"max_tool_calls": 3}}) - loaded = load_configured_extension_middlewares(_config(entry)) + loaded = load_configured_extension_middlewares(_config(entry), scope="lead") assert len(loaded) == 1 assert isinstance(loaded[0], RecordingMiddleware) @@ -54,7 +54,7 @@ def test_dict_entry_passes_constructor_kwargs(): def test_raw_dict_entry_passes_constructor_kwargs(): - loaded = load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "kwargs": {"max_tool_calls": 2}})) + loaded = load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "kwargs": {"max_tool_calls": 2}}), scope="lead") assert len(loaded) == 1 assert isinstance(loaded[0], RecordingMiddleware) @@ -63,13 +63,13 @@ def test_raw_dict_entry_passes_constructor_kwargs(): def test_malformed_raw_dict_fails_at_load(): with pytest.raises(ValidationError): - load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "apply_to": "lead"})) + load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "unexpected": "lead"}), scope="lead") def test_empty_kwargs_matches_zero_arg_constructor(): entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:RecordingMiddleware"}) - loaded = load_configured_extension_middlewares(_config(entry)) + loaded = load_configured_extension_middlewares(_config(entry), scope="lead") assert loaded[0].max_tool_calls == 10 @@ -78,7 +78,7 @@ def test_unknown_constructor_kwarg_fails_loudly(): entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:ZeroArgMiddleware", "kwargs": {"not_a_param": 1}}) with pytest.raises(TypeError): - load_configured_extension_middlewares(_config(entry)) + load_configured_extension_middlewares(_config(entry), scope="lead") def test_extensions_config_keeps_string_entries(): @@ -106,7 +106,7 @@ def test_extensions_config_parses_class_and_kwargs(): def test_extensions_config_rejects_unknown_entry_fields(): with pytest.raises(ValidationError): - ExtensionsConfig.model_validate({"middlewares": [{"class": "pkg:Middleware", "apply_to": "lead"}]}) + ExtensionsConfig.model_validate({"middlewares": [{"class": "pkg:Middleware", "unexpected": "lead"}]}) def test_extensions_config_rejects_blank_class_path(): @@ -154,7 +154,7 @@ def test_raw_file_round_trips_kwargs_entries(tmp_path, monkeypatch): raw = { "middlewares": [ "pkg:Plain", - {"class": "pkg:WithArgs", "kwargs": {"max_tool_calls": 5, "token": "$DEERFLOW_TEST_MIDDLEWARE_TOKEN"}}, + {"class": "pkg:WithArgs", "kwargs": {"max_tool_calls": 5, "token": "$DEERFLOW_TEST_MIDDLEWARE_TOKEN"}, "apply_to": "lead"}, ] } config_path.write_text(json.dumps(raw), encoding="utf-8") @@ -172,4 +172,5 @@ def test_raw_file_round_trips_kwargs_entries(tmp_path, monkeypatch): assert dumped["middlewares"][1]["class"] == "pkg:WithArgs" assert dumped["middlewares"][1]["kwargs"] == {"max_tool_calls": 5, "token": "$DEERFLOW_TEST_MIDDLEWARE_TOKEN"} assert restored.middlewares[1].class_path == "pkg:WithArgs" + assert restored.middlewares[1].apply_to == "lead" assert restored.middlewares[1].kwargs == {"max_tool_calls": 5, "token": "test-secret"} diff --git a/backend/tests/test_configured_middleware_scope.py b/backend/tests/test_configured_middleware_scope.py new file mode 100644 index 0000000000..74d2791f4a --- /dev/null +++ b/backend/tests/test_configured_middleware_scope.py @@ -0,0 +1,136 @@ +"""Scope selection for operator-configured middleware, through real builders.""" + +from types import SimpleNamespace + +import pytest +from langchain.agents.middleware import AgentMiddleware +from pydantic import ValidationError + +from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares +from deerflow.config.app_config import AppConfig +from deerflow.config.extensions_config import ConfiguredMiddlewareSpec, ExtensionsConfig + + +class ScopedMiddleware(AgentMiddleware): + def __init__(self, label="legacy"): + self.label = label + + +class BrokenMiddleware(AgentMiddleware): + def __init__(self): + raise RuntimeError("constructor executed") + + +def _entry(label, scope): + return {"class": f"{__name__}:ScopedMiddleware", "kwargs": {"label": label}, "apply_to": scope} + + +def _config(entries): + return SimpleNamespace(extensions=ExtensionsConfig.model_validate({"middlewares": entries})) + + +@pytest.mark.parametrize("scope", ["both", "lead", "subagent"]) +def test_scope_survives_config_serialization(scope): + config = ExtensionsConfig.model_validate({"middlewares": [_entry("configured", scope)]}) + restored = ExtensionsConfig.model_validate_json(config.model_dump_json(by_alias=True)) + assert restored.middlewares[0].apply_to == scope + assert restored.middlewares[0].kwargs == {"label": "configured"} + + +@pytest.mark.parametrize("invalid", [None, "", "worker", "LEAD", ["lead"], {}, True, 1]) +def test_invalid_scope_is_rejected_even_for_an_unavailable_class(invalid): + with pytest.raises(ValidationError, match="apply_to"): + ExtensionsConfig.model_validate({"middlewares": [{"class": "unavailable_module:Middleware", "apply_to": invalid}]}) + + +@pytest.mark.parametrize("scope", ["lead", "subagent"]) +def test_legacy_entries_and_order_are_preserved(scope): + config = _config( + [ + f"{__name__}:ScopedMiddleware", + {"class": f"{__name__}:ScopedMiddleware", "kwargs": {"label": "object-default"}}, + _entry("lead", "lead"), + _entry("both", "both"), + _entry("subagent", "subagent"), + ] + ) + loaded = load_configured_extension_middlewares(config, scope=scope) + expected = ["legacy", "object-default", "lead", "both"] if scope == "lead" else ["legacy", "object-default", "both", "subagent"] + assert [mw.label for mw in loaded] == expected + again = load_configured_extension_middlewares(config, scope=scope) + assert all(first is not second for first, second in zip(loaded, again, strict=True)) + assert config.extensions.middlewares[2].kwargs == {"label": "lead"} + + +@pytest.mark.parametrize("scope,excluded", [("lead", "subagent"), ("subagent", "lead")]) +@pytest.mark.parametrize("class_path", ["unavailable_module:Middleware", f"{__name__}:BrokenMiddleware"]) +def test_excluded_entries_are_not_imported_or_constructed(scope, excluded, class_path): + config = _config([{"class": class_path, "apply_to": excluded}, _entry("selected", scope)]) + loaded = load_configured_extension_middlewares(config, scope=scope) + assert [mw.label for mw in loaded] == ["selected"] + + +@pytest.mark.parametrize("scope", ["lead", "subagent"]) +def test_selected_constructor_failure_still_propagates(scope): + config = _config([{"class": f"{__name__}:BrokenMiddleware", "apply_to": scope}]) + with pytest.raises(RuntimeError, match="constructor executed"): + load_configured_extension_middlewares(config, scope=scope) + + +@pytest.mark.parametrize("scope", ["lead", "subagent"]) +@pytest.mark.parametrize("class_path", ["unavailable_module:Middleware", "types:SimpleNamespace"]) +def test_selected_import_and_subclass_failures_still_propagate(scope, class_path): + config = _config([{"class": class_path, "apply_to": scope}]) + with pytest.raises((ImportError, ValueError)): + load_configured_extension_middlewares(config, scope=scope) + + +def test_raw_dict_scope_is_validated_before_skipping(): + raw = {"class": "unavailable_module:Middleware", "apply_to": "subagent", "unexpected": True} + config = SimpleNamespace(extensions=SimpleNamespace(middlewares=[raw])) + with pytest.raises(ValidationError, match="unexpected"): + load_configured_extension_middlewares(config, scope="lead") + + +def test_raw_dict_and_model_entries_are_filtered(): + config = SimpleNamespace(extensions=SimpleNamespace(middlewares=[_entry("lead", "lead"), ConfiguredMiddlewareSpec.model_validate(_entry("subagent", "subagent"))])) + assert [mw.label for mw in load_configured_extension_middlewares(config, scope="lead")] == ["lead"] + + +@pytest.mark.parametrize("scope", ["both", "invalid", None]) +def test_loader_requires_a_concrete_host_scope(scope): + with pytest.raises(ValueError, match="scope"): + load_configured_extension_middlewares(_config([]), scope=scope) + + +def test_loader_requires_explicit_scope(): + config = _config([_entry("lead", "lead"), _entry("subagent", "subagent")]) + with pytest.raises(TypeError, match="required keyword-only argument: 'scope'"): + load_configured_extension_middlewares(config) + + +def test_real_lead_and_subagent_builders_select_their_own_scope(tmp_path, monkeypatch): + from deerflow.agents.lead_agent.agent import build_middlewares + from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares + from deerflow.extensions import get_agent_build_extensions + + monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "runtime")) + app_config = AppConfig.model_validate( + { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": [], + "memory": {"enabled": False}, + "summarization": {"enabled": False}, + "extensions": {"middlewares": [_entry("lead", "lead"), _entry("shared", "both"), _entry("subagent", "subagent")]}, + } + ) + extensions = get_agent_build_extensions() + lead = build_middlewares({}, None, app_config=app_config, memory_enabled=False, available_skills=set(), extensions=extensions) + subagent = build_subagent_runtime_middlewares(app_config=app_config, available_skills=set(), extensions=extensions) + assert [mw.label for mw in lead if isinstance(mw, ScopedMiddleware)] == ["lead", "shared"] + assert [mw.label for mw in subagent if isinstance(mw, ScopedMiddleware)] == ["shared", "subagent"] + assert next(mw for mw in lead if isinstance(mw, ScopedMiddleware) and mw.label == "shared") is not next(mw for mw in subagent if isinstance(mw, ScopedMiddleware) and mw.label == "shared") + for chain in (lead, subagent): + configured_end = max(i for i, mw in enumerate(chain) if isinstance(mw, ScopedMiddleware)) + length_guard = next(i for i, mw in enumerate(chain) if type(mw).__name__ == "ModelLengthFinishReasonMiddleware") + assert configured_end < length_guard diff --git a/config.example.yaml b/config.example.yaml index 8061f4a78a..9674acc50c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -75,6 +75,7 @@ logging: # middlewares: # - my_company.deerflow_middlewares:DomainGuardMiddleware # - class: my_company.deerflow_middlewares:LatencyStampingMiddleware +# apply_to: lead # both (default), lead, or subagent # kwargs: # header: X-DeerFlow-Latency diff --git a/frontend/src/content/en/harness/customization.mdx b/frontend/src/content/en/harness/customization.mdx index 3df030b752..caba344eae 100644 --- a/frontend/src/content/en/harness/customization.mdx +++ b/frontend/src/content/en/harness/customization.mdx @@ -51,13 +51,16 @@ extensions: middlewares: - my_company.deerflow_middlewares:AuditMiddleware - class: my_company.deerflow_middlewares:AuditMiddleware + apply_to: lead kwargs: max_tool_calls: 5 ``` +Optional `apply_to` on object entries accepts `both` (default), `lead`, or `subagent`. String entries and objects without this field retain both chains. Invalid values fail configuration validation. The host selects the chain before importing or constructing a middleware, so an excluded entry has no import/constructor side effects in that chain. Selected entries keep their relative order. This selects the fixed-slot configuration path only; packaged extensions retain their existing scope and placement APIs. + Configured middleware is loaded after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response or clarification stage, so configured middleware is followed by the model-length guard, the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat these class paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[AuditMiddleware()])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[AuditMiddleware()])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. -Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`. +Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines by default. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`. For `create_deerflow_agent`, an `@Next` or `@Prev` anchor must name a middleware that this smaller chain actually contains; anchors from the full middleware list otherwise fail to resolve. diff --git a/frontend/src/content/en/harness/middlewares.mdx b/frontend/src/content/en/harness/middlewares.mdx index ca37881919..37d3017bdc 100644 --- a/frontend/src/content/en/harness/middlewares.mdx +++ b/frontend/src/content/en/harness/middlewares.mdx @@ -263,10 +263,13 @@ extensions: middlewares: - my_company.deerflow_middlewares:MyMiddleware - class: my_company.deerflow_middlewares:MyMiddleware + apply_to: lead kwargs: max_tool_calls: 5 ``` +Optional `apply_to` on object entries accepts `both` (default), `lead`, or `subagent`. String entries and objects without this field retain both chains. Invalid values fail configuration validation. The host selects the chain before importing or constructing a middleware, so an excluded entry has no import/constructor side effects in that chain. Selected entries keep their relative order. This selects the fixed-slot configuration path only; packaged extensions retain their existing scope and placement APIs. + Configured middleware runs after the built-in middleware and optional loop/token guards. On the lead-agent pipeline, it runs before the terminal-response, model-length, safety, and clarification tail; subagents have no terminal-response or clarification stage, so configured middleware is followed by the model-length guard, the optional safety guard, `DurableContextMiddleware`, optional `SummarizationMiddleware`, then `SubagentDateContextMiddleware` and `SystemMessageCoalescingMiddleware`. Treat middleware paths as trusted configuration because loading one executes Python code. Embedded callers can instead use `DeerFlowClient(middlewares=[...])`, which builds the full lead-agent chain and places middleware before its terminal-response, model-length, safety, and clarification tail. `create_deerflow_agent(extra_middleware=[...])` instead builds a smaller feature-based lead-agent chain; unanchored extras are placed immediately before `ClarificationMiddleware` (anchored extras follow their `@Next`/`@Prev` placement). Neither API forwards middleware to subagents. -Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[MyMiddleware()])`. +Choose the registration path by ownership and placement. The fixed-slot (not deprecated) `extensions.middlewares` list is accepted in `config.yaml` and `extensions_config.json` (`config.yaml` wins) and applies to both lead and subagent pipelines by default. Packaged extensions registered through the top-level `plugins:` list contribute middleware at semantic extension points. Contributor code that needs committed, programmatic lead-only wiring can use `build_middlewares(..., custom_middlewares=[MyMiddleware()])`. diff --git a/frontend/src/content/zh/harness/customization.mdx b/frontend/src/content/zh/harness/customization.mdx index 4cbb55ad12..b3f99be083 100644 --- a/frontend/src/content/zh/harness/customization.mdx +++ b/frontend/src/content/zh/harness/customization.mdx @@ -50,13 +50,16 @@ extensions: middlewares: - my_company.deerflow_middlewares:AuditMiddleware - class: my_company.deerflow_middlewares:AuditMiddleware + apply_to: lead kwargs: max_tool_calls: 5 ``` +对象条目可选 `apply_to`,取值为 `both`(默认)、`lead` 或 `subagent`。字符串条目和省略该字段的对象继续作用于两条链。非法取值在配置校验时失败。宿主先选择作用对象,再导入和构造中间件,因此被排除的条目不会在该链产生导入或构造副作用;入选条目保持原有相对顺序。这只选择固定槽位配置路径;打包扩展继续使用已有的作用范围和放置接口。 + 配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应或澄清阶段,因此配置中间件之后会继续执行模型长度防护、可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[AuditMiddleware()])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[AuditMiddleware()])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 -应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),并同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`。 +应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),默认同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[AuditMiddleware()])`。 对于 `create_deerflow_agent`,`@Next` 或 `@Prev` 锚点必须指向这个较小链中实际存在的中间件;否则使用完整中间件列表中的锚点会解析失败。 diff --git a/frontend/src/content/zh/harness/middlewares.mdx b/frontend/src/content/zh/harness/middlewares.mdx index b730af1ace..8045c57c4b 100644 --- a/frontend/src/content/zh/harness/middlewares.mdx +++ b/frontend/src/content/zh/harness/middlewares.mdx @@ -246,10 +246,13 @@ extensions: middlewares: - my_company.deerflow_middlewares:MyMiddleware - class: my_company.deerflow_middlewares:MyMiddleware + apply_to: lead kwargs: max_tool_calls: 5 ``` +对象条目可选 `apply_to`,取值为 `both`(默认)、`lead` 或 `subagent`。字符串条目和省略该字段的对象继续作用于两条链。非法取值在配置校验时失败。宿主先选择作用对象,再导入和构造中间件,因此被排除的条目不会在该链产生导入或构造副作用;入选条目保持原有相对顺序。这只选择固定槽位配置路径;打包扩展继续使用已有的作用范围和放置接口。 + 配置的中间件会在内置中间件及可选的循环/token 保护之后加载。对于主 Agent 链,它位于终态响应、模型长度、安全和澄清尾部之前;子 Agent 链没有终态响应或澄清阶段,因此配置中间件之后会继续执行模型长度防护、可选的安全防护、`DurableContextMiddleware`、可选的 `SummarizationMiddleware`,随后是 `SubagentDateContextMiddleware` 和 `SystemMessageCoalescingMiddleware`。中间件路径会执行 Python 代码,因此应视为可信配置。嵌入式调用方也可以使用 `DeerFlowClient(middlewares=[...])`;它构建完整的主 Agent 链,并将中间件放在其终态响应、模型长度、安全和澄清尾部之前。`create_deerflow_agent(extra_middleware=[...])` 则构建较小的按功能组装的主 Agent 链;未锚定的额外中间件会紧接在 `ClarificationMiddleware` 之前放置(锚定中间件遵循其 `@Next`/`@Prev` 位置)。两个 API 均不会将中间件转发给子 Agent。 -应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),并同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[MyMiddleware()])`。 +应根据配置的所有者和插入位置选择注册方式。固定槽位(并未弃用)`extensions.middlewares` 可写在 `config.yaml` 或 `extensions_config.json` 中(前者优先),默认同时作用于主 Agent 和子 Agent。通过顶层 `plugins:` 注册的打包扩展可在语义化扩展点贡献中间件。若贡献代码需要可提交、可编程且仅作用于主 Agent 的接线,请使用 `build_middlewares(..., custom_middlewares=[MyMiddleware()])`。