diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d4424097..b37212814d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1389,6 +1389,11 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Changed +- **tool output:** Superseded `write_file` payload elision is now opt-in. The + optimization remains available, but the default keeps model-bound history + append-only so later file actions do not invalidate provider prompt-cache + prefixes. ([#7319](https://github.com/bytedance/deer-flow/issues/7319)) + - **skills:** Treat `allowed-tools` as non-enforced compatibility metadata. Activating a skill no longer hides or blocks tools that Agent configuration and host authorization already permit, including when the declaration is diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index f36fbae26b..354e256c0a 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1106,6 +1106,10 @@ ### 变更 +- **工具输出:** 被后续操作取代的 `write_file` 载荷省略现在改为显式启用。该优化仍可 + 使用,但默认会保持发送给模型的历史只追加,避免后续文件操作使 provider 的提示词缓存 + 前缀失效。([#7319](https://github.com/bytedance/deer-flow/issues/7319)) + - **技能:** 将 `allowed-tools` 作为不强制执行的兼容性元数据。激活技能不再隐藏或 阻止智能体配置与宿主授权已经允许的工具,即使该声明为空也是如此。保留解析、校验、 导出、技能激活权限,以及现有的密钥和用户隔离边界。([#6935]) diff --git a/README.md b/README.md index 8cf6860206..5efcc25f69 100644 --- a/README.md +++ b/README.md @@ -2833,6 +2833,8 @@ An explicit zero per-tool override disables externalization while preserving any positive global fallback limit. Structured JSON tool results follow these budgets, including results mixed with media; externalization keeps the full JSON available through a file reference. +Superseded `write_file` payload elision is opt-in because enabling it rewrites +earlier model-request prefixes when later file actions supersede a write. MindIE renders JSON tool results as escaped text, with configured tool exemptions preserved. Enabled PII redaction scans nested JSON keys and values before rendering. Mounted sandboxes running under another UID need read access through the shared diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index ce923eafab..3d11e8be96 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -61,6 +61,8 @@ No match: skip inspection, log ID. Tests: `test_read_mark_tool_call_correlation. **KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search`, `knowledge_entries_search` and `entity_relations_search` when disabled without reading storage or RAGFlow. 2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with limits/exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with synopsis/`read_file` refs. Preserve mixed-result media. Restore blob refs across hosts using the recovering Gateway's normal umask; hash/write failures fall back inline. Internal files bypass workspace/delivery scans. Publication/elision/bash exit marker: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision: `tool_call_args`; retain newest `keep_recent_writes` writes. The placeholder comes from `WRITE_ELISION_PLACEHOLDER_TEMPLATE` (`constants.py`) and `write_file` rejects a payload that *is* that placeholder (whole payload, up to surrounding whitespace), so an imitated placeholder cannot become a deliverable (#7319). Controls: `elide_superseded_writes`, `superseded_write_min_chars`. + Superseded-write elision is opt-in: enabling it reduces context but rewrites + earlier model-request prefixes when later actions supersede writes (#7319). Tests: `tests/test_tool_output_synopsis_budget.py`. 3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. ``) and boundary markers in *remote-content* tool results (`web_fetch`/`web_search`/`image_search`/`web_capture`) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors `InputSanitizationMiddleware`'s user-input guardrail for the other untrusted-content entry point; sits inner of `ToolOutputBudgetMiddleware`. Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist for the first-party web tools, plus every MCP-sourced tool via its `deerflow_mcp` metadata tag, so an MCP server naming its fetcher `fetch_url` is still covered diff --git a/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md b/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md index dbde2d50b4..23be92661b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md +++ b/backend/packages/harness/deerflow/agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md @@ -78,12 +78,15 @@ writers' files based on a TTL. ## Model-bound write elision -`wrap_model_call` elides successful `write_file` content only in model requests -after a later successful same-path `read_file`, `write_file`, or `str_replace` -(#5328); disk remains the reference. `keep_recent_writes` preserves the newest -writes. Use the shared `tool_call_args` helpers (`pair_tool_call_results` and -argument rewriting). Controls: `elide_superseded_writes` and -`superseded_write_min_chars`. +`wrap_model_call` can elide successful `write_file` content only in model +requests after a later successful same-path `read_file`, `write_file`, or +`str_replace` (#5328); disk remains the reference. The optimization is disabled +by default because applying it after a later action rewrites an earlier request +prefix and reduces provider prompt-cache reuse (#7319). Explicitly enable +`elide_superseded_writes` to accept that trade-off. `keep_recent_writes` +preserves the newest writes. Use the shared `tool_call_args` helpers +(`pair_tool_call_results` and argument rewriting). Controls: +`elide_superseded_writes` and `superseded_write_min_chars`. The placeholder is framework-owned text that the model can see in its own history, so it can be imitated. `write_file` therefore rejects a payload that diff --git a/backend/packages/harness/deerflow/config/tool_output_config.py b/backend/packages/harness/deerflow/config/tool_output_config.py index 6eafce1f7a..61b106cc76 100644 --- a/backend/packages/harness/deerflow/config/tool_output_config.py +++ b/backend/packages/harness/deerflow/config/tool_output_config.py @@ -27,11 +27,12 @@ class ToolOutputConfig(BaseModel): 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 + The same middleware can also budget 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). + made the historical copy redundant with the file on disk. This behavior is + opt-in through ``elide_superseded_writes`` because it rewrites an earlier + request prefix (issues #5328 and #7319). """ enabled: bool = Field( @@ -97,13 +98,14 @@ class ToolOutputConfig(BaseModel): 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, + default=False, description=( - "Replace the content argument of a successful write_file call with a short placeholder in model-bound " + "Opt in to replacing 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." + "the next modification, so the historical copy is redundant with that read. This is disabled by default " + "because enabling it rewrites an earlier model-request prefix when a later action supersedes the write. " + "Only the request copy changes: stored message history, receipts, and the run journal keep the original arguments." ), ) superseded_write_min_chars: _OutputLimit = Field( diff --git a/backend/tests/test_tool_output_budget_middleware.py b/backend/tests/test_tool_output_budget_middleware.py index fa6022b344..18ee7b57ef 100644 --- a/backend/tests/test_tool_output_budget_middleware.py +++ b/backend/tests/test_tool_output_budget_middleware.py @@ -2440,6 +2440,9 @@ class TestSupersededWriteElision: @staticmethod def _middleware(**overrides) -> ToolOutputBudgetMiddleware: + # Elision remains available as an explicit opt-in; behavior-focused + # tests enable it so changing the safe default does not weaken them. + overrides.setdefault("elide_superseded_writes", True) return ToolOutputBudgetMiddleware(config=ToolOutputConfig(**overrides)) @staticmethod @@ -2708,6 +2711,20 @@ class TestSupersededWriteElision: assert forwarded is request + def test_default_config_keeps_model_history_append_only(self): + mw = ToolOutputBudgetMiddleware(config=ToolOutputConfig()) + w1, r1 = _write("call-1", self.PATH, "x" * 5000) + rd, rr = _read("call-2", self.PATH) + w2, r2 = _write("call-3", self.OTHER, "tiny") + + first_request, first_forwarded = self._forward(mw, [w1, r1]) + later_request, later_forwarded = self._forward(mw, [w1, r1, rd, rr, w2, r2]) + + assert first_forwarded is first_request + assert later_forwarded is later_request + assert self._content(first_forwarded, 0) == "x" * 5000 + assert self._content(later_forwarded, 0) == self._content(first_forwarded, 0) + def test_middleware_disabled_passes_request_through(self): mw = self._middleware(enabled=False) w1, r1 = _write("call-1", self.PATH, "x" * 5000) @@ -2720,7 +2737,7 @@ class TestSupersededWriteElision: def test_config_defaults(self): config = ToolOutputConfig() - assert config.elide_superseded_writes is True + assert config.elide_superseded_writes is False assert config.superseded_write_min_chars == 2000 assert config.keep_recent_writes == 1 @@ -2743,7 +2760,7 @@ class TestSupersededWriteElision: with open(example_path, encoding="utf-8") as f: data = yaml.safe_load(f) tool_output = data["tool_output"] - assert tool_output["elide_superseded_writes"] is True + assert tool_output["elide_superseded_writes"] is False assert tool_output["superseded_write_min_chars"] == 2000 assert tool_output["keep_recent_writes"] == 1 # New user-settable keys are a schema change: the outdated-config warning must fire. diff --git a/config.example.yaml b/config.example.yaml index e2ebc53ad0..84adfa82e0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1422,10 +1422,10 @@ tool_search: # # The same middleware also elides the `content` argument of successful # write_file calls from model-bound requests once the same path was read or -# modified again later (issue #5328): the file on disk is then the source of -# truth, and the read-before-write gate forces a read_file before the next -# modification anyway, so the historical copy only costs context. Stored -# history, receipts, and the run journal keep the original arguments. +# modified again later (issue #5328). This optimization is opt-in because a +# later superseding action rewrites an earlier model-request prefix, reducing +# provider prompt-cache reuse (#7319). Stored history, receipts, and the run +# journal keep the original arguments when it is enabled. tool_output: enabled: true @@ -1444,10 +1444,11 @@ tool_output: # tool_overrides: # web_search: 8000 # bash: 20000 - # Replace a successful write_file call's `content` with a short placeholder - # pointing at read_file once a later successful read_file / write_file / - # str_replace of the same path exists. Only the model-bound request changes. - elide_superseded_writes: true + # Opt in to replacing a successful write_file call's `content` with a short + # placeholder pointing at read_file once a later successful read_file / + # write_file / str_replace of the same path exists. Only the model-bound + # request changes, but doing so rewrites an earlier prompt prefix. + elide_superseded_writes: false # Only elide content at least this many characters long (a character count, # not tokens: CJK text costs ~3-4x more per character than ASCII). 0 elides # every non-empty content.