mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 01:45:58 +00:00
* fix(#3189): prevent write_file streaming timeout on long reports Adds a layered defense against StreamChunkTimeoutError caused by oversized single-shot write_file tool calls: - factory: default stream_chunk_timeout to 240s for OpenAI-compatible clients (overridable via ModelConfig.stream_chunk_timeout in config.yaml) - sandbox/tools: server-side 80 KB length guard on non-append write_file calls (configurable via DEERFLOW_WRITE_FILE_MAX_BYTES env var, 0 disables); rejects oversized payloads with a structured error pointing the model at str_replace or append=True - middleware: classify StreamChunkTimeoutError as transient but cap retries at 1 via per-exception _RETRY_BUDGET_OVERRIDES (same-payload retry on a chunk-gap timeout buffers the same way upstream; full 3-attempt loop would stack 6-12 min of dead air) - middleware: surface an actionable user-facing message for stream-drop exceptions instead of leaking the raw langchain stack - prompts: add a routing-style File Editing Workflow hint to both lead_agent and general_purpose subagent prompts, pointing the model at str_replace for incremental edits (mirrors Claude Code's Edit / Codex's apply_patch) - tests: behavioural coverage for size guard, retry budget override, stream-drop user message, factory default injection Refs #3189 * fix(#3189): drop stream_chunk_timeout for non-OpenAI providers Address CR feedback on PR #3195: - factory: pop `stream_chunk_timeout` from kwargs for any model_use_path other than `langchain_openai:ChatOpenAI` instead of returning early. `ModelConfig.stream_chunk_timeout` is part of the shared schema, so a user-supplied value on a non-OpenAI provider would otherwise be forwarded to its constructor and raise `TypeError: unexpected keyword argument`. - factory: rewrite docstring to describe the actual `exclude_none=True` behaviour (explicit null is excluded and falls back to the default) instead of the misleading "None falling out via exclude_none=True keeps its value". - tests: add regression coverage asserting the kwarg is stripped before reaching a non-OpenAI provider's constructor. Refs: bytedance#3189 * fix(#3189): restrict stream-drop user copy to StreamChunkTimeoutError only Per CR on #3195: narrow _STREAM_DROP_EXCEPTIONS to StreamChunkTimeoutError. Generic httpx RemoteProtocolError / ReadError fall back to the standard 'temporarily unavailable' copy, since they routinely fire on transient network blips where the 'split the output' guidance is misleading. Retry/backoff classification is unchanged — both remain transient/retriable. Tests updated to reflect new copy, plus a symmetric regression test for ReadError. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
@@ -43,6 +44,16 @@ _MAX_GLOB_MAX_RESULTS = 1000
|
||||
_DEFAULT_GREP_MAX_RESULTS = 100
|
||||
_MAX_GREP_MAX_RESULTS = 500
|
||||
_DEFAULT_WRITE_FILE_ERROR_MAX_CHARS = 2000
|
||||
|
||||
# Maximum bytes accepted in a single non-append write_file call (issue #3189).
|
||||
# Oversized single-shot writes correlate with LLM streaming chunk-gap timeouts
|
||||
# because the tool-call JSON payload (which the model must emit as one
|
||||
# continuous stream) grows past the safe window. 80 KB ≈ 20K tokens, a
|
||||
# comfortable headroom under the factory-default 240s stream_chunk_timeout.
|
||||
# Deployments can override via env var DEERFLOW_WRITE_FILE_MAX_BYTES; set to
|
||||
# 0 (or negative) to disable the guard entirely.
|
||||
_WRITE_FILE_CONTENT_MAX_BYTES = 80 * 1024
|
||||
_WRITE_FILE_MAX_BYTES_ENV = "DEERFLOW_WRITE_FILE_MAX_BYTES"
|
||||
_LOCAL_BASH_CWD_COMMANDS = {"cd", "pushd"}
|
||||
_LOCAL_BASH_COMMAND_WRAPPERS = {"command", "builtin"}
|
||||
_LOCAL_BASH_COMMAND_PREFIX_KEYWORDS = {"!", "{", "case", "do", "elif", "else", "for", "if", "select", "then", "time", "until", "while"}
|
||||
@@ -1671,6 +1682,23 @@ async def _read_file_tool_async(
|
||||
read_file_tool.coroutine = _read_file_tool_async
|
||||
|
||||
|
||||
def _effective_write_file_max_bytes() -> int:
|
||||
"""Return the active size cap for non-append write_file calls.
|
||||
|
||||
Reads ``DEERFLOW_WRITE_FILE_MAX_BYTES`` at call time (not import time)
|
||||
so tests and runtime tweaks take effect without restart. Falls back to
|
||||
the default on missing/malformed values. A non-positive value disables
|
||||
the guard.
|
||||
"""
|
||||
raw = os.environ.get(_WRITE_FILE_MAX_BYTES_ENV)
|
||||
if raw is None:
|
||||
return _WRITE_FILE_CONTENT_MAX_BYTES
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return _WRITE_FILE_CONTENT_MAX_BYTES
|
||||
|
||||
|
||||
@tool("write_file", parse_docstring=True)
|
||||
def write_file_tool(
|
||||
runtime: Runtime,
|
||||
@@ -1679,14 +1707,47 @@ def write_file_tool(
|
||||
content: str,
|
||||
append: bool = False,
|
||||
) -> str:
|
||||
"""Write text content to a file. By default this overwrites the target file; set append to true to add content to the end without replacing existing content.
|
||||
"""Write text content to a file. By default this overwrites the target file; set append=True to add content to the end without replacing existing content.
|
||||
|
||||
SIZE POLICY (issue #3189):
|
||||
A single non-append write_file call must not exceed 80 KB of UTF-8 content.
|
||||
Oversized single-shot writes correlate with LLM streaming chunk-gap
|
||||
timeouts because the tool-call JSON payload — which the model must emit as
|
||||
one continuous stream — grows past the safe window. For larger documents,
|
||||
use ONE of these strategies (write_file rejects oversized payloads with an
|
||||
actionable error):
|
||||
|
||||
1. INCREMENTAL EDIT (preferred for revisions): after the initial write,
|
||||
use `str_replace` to surgically update sections. This is the same
|
||||
pattern Claude Code's Write+Edit and OpenAI Codex's apply_patch use,
|
||||
and keeps each tool call's payload small.
|
||||
2. APPEND-IN-CHUNKS (for new long-form content): split the document into
|
||||
sections, each well under 80 KB. First call uses append=False to
|
||||
create the file; subsequent calls use append=True. The 80 KB cap does
|
||||
NOT apply to append=True calls.
|
||||
|
||||
Operators can override the cap via env var `DEERFLOW_WRITE_FILE_MAX_BYTES`
|
||||
(0 disables the guard entirely). Raising it risks streaming timeouts.
|
||||
|
||||
Args:
|
||||
description: Explain why you are writing to this file in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the file to write to. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||
content: The content to write to the file. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||
append: Whether to append content to the end of the file instead of overwriting it. Defaults to false.
|
||||
append: Whether to append content to the end of the file instead of overwriting it. Defaults to False.
|
||||
"""
|
||||
if not append:
|
||||
max_bytes = _effective_write_file_max_bytes()
|
||||
if max_bytes > 0:
|
||||
content_bytes = len(content.encode("utf-8"))
|
||||
if content_bytes > max_bytes:
|
||||
return (
|
||||
f"Error: write_file content ({content_bytes} bytes) exceeds the "
|
||||
f"{max_bytes}-byte single-call limit. Split the content into smaller "
|
||||
"pieces: either (a) write the first section now, then use `str_replace` "
|
||||
"for further edits, or (b) call write_file again with append=True "
|
||||
"carrying the next section. See SIZE POLICY in the tool docstring "
|
||||
"or issue #3189 for the rationale."
|
||||
)
|
||||
try:
|
||||
requested_path = path
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
|
||||
Reference in New Issue
Block a user