mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-16 17:46:20 +00:00
* 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.
89 lines
3.7 KiB
Python
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.",
|
|
)
|