lllyfff 99c926b7bb
fix(mcp): bring-up has no timeout and externalized tool outputs are counted as undelivered artifacts (#4657)
* fix: bound MCP server bring-up timeouts and exclude externalized tool outputs from delivery verification

Two related robustness fixes:

1. MCP server bring-up was unbounded. tool_call_timeout only covered
   session.call_tool(); tool discovery (subprocess spawn + initialize +
   tools/list) and persistent stdio session initialization could hang
   forever, blocking agent construction (and on the Gateway event loop,
   the whole process). Add a per-server session_init_timeout
   (default DEFAULT_MCP_SESSION_INIT_TIMEOUT = 60s, null disables) that
   bounds both discovery and pooled-session initialization. The session
   pool's existing cancellation handling tears down a session stuck
   mid-creation in its own task.

2. ToolOutputBudgetMiddleware externalizes oversized tool outputs into
   outputs/.tool-results/ (configurable tool_output.storage_subdir). The
   workspace-change scanner and run delivery verification counted those
   files as produced artifacts, so any run that externalized a tool output
   without also presenting a real artifact failed with
   "Artifact delivery incomplete". Exclude TOOL_RESULTS_DIRNAME via a
   shared constant (mirroring BROWSER_FRAMES_DIRNAME) and thread the
   configured storage_subdir through snapshot capture so both
   workspace-changes events and delivery verification stay clean.

* review: enforce single-segment tool_output.storage_subdir; document discovery-timeout cleanup

Address review feedback:

1. A custom tool_output.storage_subdir with a path separator (e.g.
   cache/tool-results) silently no-oped the workspace-scanner exclusion:
   os.walk yields one-segment dirnames, so a nested value never matched and
   its files were counted as produced artifacts again. ToolOutputConfig now
   validates storage_subdir as a single directory name (rejects separators,
   .., absolute, empty) with tests, so the exclusion is always sound.

2. The discovery-timeout path now documents why cancellation is safe, mirroring
   the session-init note: discovery runs inside the adapter's nested async
   context managers, and stdio_client's finally terminates the process tree
   (SIGTERM->SIGKILL on POSIX, process-tree on Windows), so a timed-out npx
   subprocess and its children are reaped rather than accumulating.

* review: log session-init timeouts and align API response model default with runtime config

Address second-round review feedback:

1. A session-init timeout raised TimeoutError without any log, unlike the
   discovery timeout which logs a WARNING. Wrap the bounded get_session in a
   try/except that logs the timeout (server name + seconds) and re-raises, so
   operators can diagnose tool-call failures caused by hung MCP sessions.

2. McpServerConfigResponse.session_init_timeout defaulted to None while
   McpServerConfig defaults to 60s: a server created via PUT /api/mcp/config
   without the field was persisted with null (no timeout) while the same
   server created in the config file got 60s. Align the response-model default
   to DEFAULT_MCP_SESSION_INIT_TIMEOUT so API-created and file-created servers
   behave the same; an explicit null still opts out.

* review: narrow the discovery-timeout handler to the bounded wait_for path

The except TimeoutError clause covered both the bounded wait_for branch and
the bare discovery branch. With session_init_timeout opted out (None), a
TimeoutError raised by discovery itself would hit the %.1f format with None:
logging raises TypeError internally, the WARNING is silently dropped, and a
--- Logging error --- traceback goes to stderr.

Narrow the handler to wrap only the wait_for call, where the branch condition
guarantees the timeout value is not None. A discovery-internal TimeoutError on
the opted-out path now falls through to the generic failure handler and is
reported as 'tool discovery failed' with exc_info. Covered by a regression
test that asserts the skip is reported without any broken format.
2026-08-05 08:56:18 +08:00

89 lines
3.7 KiB
Python

"""Configuration for tool output budget protection."""
from __future__ import annotations
import os
from pydantic import BaseModel, Field, field_validator
from deerflow.constants import TOOL_RESULTS_DIRNAME
class ToolOutputConfig(BaseModel):
"""Config section for tool-result output budget enforcement.
When a tool returns more than ``externalize_min_chars`` characters,
the full output is persisted to disk and replaced with a compact
preview + file reference. If disk persistence is unavailable the
output falls back to head+tail truncation.
"""
enabled: bool = Field(
default=True,
description="Enable the tool output budget middleware.",
)
externalize_min_chars: int = Field(
default=12_000,
ge=0,
description="Character threshold to trigger disk externalization. Outputs below this pass through unchanged. Set to 0 to disable externalization (fallback truncation still applies when output exceeds fallback_max_chars).",
)
preview_head_chars: int = Field(
default=2_000,
ge=0,
description="Sampling budget retained for compatibility. Typed previews use this with preview_tail_chars only for fallback samples inside the structured synopsis.",
)
preview_tail_chars: int = Field(
default=1_000,
ge=0,
description="Sampling budget retained for compatibility. Typed previews use this with preview_head_chars only for fallback samples inside the structured synopsis.",
)
fallback_max_chars: int = Field(
default=30_000,
ge=0,
description="Maximum characters when disk persistence is unavailable. 0 disables fallback truncation.",
)
fallback_head_chars: int = Field(
default=8_000,
ge=0,
description="Head characters for fallback truncation.",
)
fallback_tail_chars: int = Field(
default=3_000,
ge=0,
description="Tail characters for fallback truncation.",
)
storage_subdir: str = Field(
default=TOOL_RESULTS_DIRNAME,
description=(
"Single-segment directory name under the thread outputs path for persisted tool results. "
"TOOL_RESULTS_DIRNAME is always excluded by the workspace-changes scanner; other custom values are "
"excluded from workspace snapshots and run delivery verification at capture time."
),
)
@field_validator("storage_subdir")
@classmethod
def _storage_subdir_is_single_segment(cls, value: str) -> str:
"""Require a single directory name (no path separators).
The workspace-changes scanner prunes by directory name during
``os.walk``, which yields one-segment dirnames — a nested value like
``cache/tool-results`` would never match the exclusion and its files
would silently be counted as produced artifacts again. A loud config
error beats a silent exclusion no-op.
"""
if value == "" or value in {".", ".."} or os.path.isabs(value):
raise ValueError("storage_subdir must be a single non-empty directory name")
if "/" in value or "\\" in value:
raise ValueError(f"storage_subdir must be a single directory name without path separators (got {value!r})")
return value
exempt_tools: list[str] = Field(
default_factory=lambda: ["read_file", "read_file_tool"],
description="Tool names exempt from budget enforcement (prevents persist→read→persist loops).",
)
tool_overrides: dict[str, int] = Field(
default_factory=dict,
description="Per-tool externalize_min_chars overrides. Keys are tool names, values are char thresholds. Use 0 to disable externalization for a specific tool.",
)