mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
* feat(agents): elide superseded write_file payloads from model-bound requests Step 2 of #5328. After a successful write_file the file on disk is the source of truth, and the read-before-write gate forces a read_file before the next modification of that path, so once a later successful read or write of the same path exists the historical `content` argument is redundant with it. Long report-writing runs (append-in-chunks) therefore carried every section twice, once as the write argument and once as the following read output, until summarization compacted the whole turn. - ToolOutputBudgetMiddleware's model-call hooks now replace such superseded content with a short deterministic placeholder pointing at read_file, in the model-bound request only: state["messages"], checkpoints, receipts, loop detection, and the run journal keep the original arguments, and nothing is externalized to disk. The newest `keep_recent_writes` successful writes (default 1) always stay visible; str_replace payloads are never touched; a same-turn read never supersedes (parallel calls run in no fixed order); only results stamped deerflow_tool_meta.status == "success" count, so failed, gate-blocked, partial, or unstamped writes are never candidates. - New `tool_output.elide_superseded_writes` (default on), `tool_output.superseded_write_min_chars` (default 2000), and `tool_output.keep_recent_writes` (default 1); config_version 41 -> 42 in config.example.yaml and the Helm chart. - The per-occurrence call/result pairing the gate introduced in #5329 moves into the shared `tool_call_args.pair_tool_call_results` helper so both policies pair the same way; the gate now uses it. * fix(agents): scope tool-call result pairing to the issuing turn Review finding on #5374 (P2): pair_tool_call_results consumed results from a history-wide per-id queue, so an interrupted write_file with no result whose tool-call id a later turn reused inherited that later call's success. With the default elision the unconfirmed draft was then replaced by a placeholder claiming the write succeeded, and the gate's blocked-call pairing had the mirror-image hole. Pair results the way DanglingToolCallMiddleware does: walk in document order, open each AIMessage's calls, and let a ToolMessage answer only a still-open call of the most recent preceding AIMessage. A result never answers a call from an earlier turn, so the interrupted call stays unanswered (never a candidate, never labeled blocked) and stray or duplicate results are ignored. Regressions cover the helper, the superseded-write policy, and the gate. * fix(agents): never rewrite tool-call ids duplicated within one AIMessage Review finding on #5374 (P2): the policies select calls per occurrence, but every provider surface is addressed by tool-call id, so when a malformed provider payload repeats an id inside one assistant turn the rewriter could only replace all of its occurrences at once. A failed write_file sibling then took on the superseded successful call's path and elided content and was presented as a success; the gate's blocked-call elision had the mirror-image hole (a successful sibling rewritten into the blocked call). rewrite_messages_tool_call_args now never offers an id that repeats within its message to the selector and leaves those calls untouched on every surface. Both policies are covered by the shared helper; regressions cover the helper, the superseded-write policy, and the gate. * fix(agents): skip unhashable tool-call ids in the duplicate-id guard Review finding on #5374 (round 3): _duplicated_call_ids fed every id into a Counter before the string guard, so a list or dict id from a malformed provider payload raised TypeError out of wrap_model_call and failed the whole model call whenever the history also held a rewrite candidate. The pre-PR loop and pair_tool_call_results skip such ids; only this helper regressed. Count non-empty string ids only, and pin it with regressions for the helper, the superseded-write policy, and the gate. * docs(agents): keep middleware guidance within size limit --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
120 lines
5.5 KiB
Python
120 lines
5.5 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.
|
|
|
|
The same middleware also budgets the other bulky side of a tool call in
|
|
model-bound requests: the ``content`` argument of a successful
|
|
``write_file`` call, once a later read or write of the same path has
|
|
made the historical copy redundant with the file on disk
|
|
(``elide_superseded_writes``; issue #5328).
|
|
"""
|
|
|
|
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.",
|
|
)
|
|
elide_superseded_writes: bool = Field(
|
|
default=True,
|
|
description=(
|
|
"Replace the content argument of a successful write_file call with a short placeholder in model-bound "
|
|
"requests once the same path was read or modified again later in the conversation. After a successful "
|
|
"write the file on disk is the source of truth, and the read-before-write gate forces a read_file before "
|
|
"the next modification, so the historical copy is redundant with that read. Only the request copy "
|
|
"changes: stored message history, receipts, and the run journal keep the original arguments."
|
|
),
|
|
)
|
|
superseded_write_min_chars: int = Field(
|
|
default=2000,
|
|
ge=0,
|
|
description=(
|
|
"Elide only write_file content at least this many characters long; shorter content stays visible. "
|
|
"0 elides every non-empty content. This is a Python character count, not a token count: the same value "
|
|
"spans roughly 3-4x in real context cost between ASCII and CJK text, and the placeholder's elided-size "
|
|
"figure is the same character count."
|
|
),
|
|
)
|
|
keep_recent_writes: int = Field(
|
|
default=1,
|
|
ge=0,
|
|
description="Never elide the content of the newest N successful write_file calls (counted across all paths), even when superseded, so the model can still say what it just wrote without a read. 0 keeps none.",
|
|
)
|