fix(agents): bound structured tool-output previews (#6422)

* fix(agents): bound structured tool-output previews

* docs(agents): keep synopsis guidance within context budget
This commit is contained in:
ZJPex 2026-10-07 17:07:59 +08:00 committed by GitHub
parent 1d3e68363c
commit 66dfba31e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 180 additions and 16 deletions

View File

@ -2154,6 +2154,8 @@ request the binary capability retain the legacy JSON/base64 frame protocol.
**Summarization**: Within a session, DeerFlow manages context aggressively — summarizing completed sub-tasks, offloading intermediate results to the filesystem, compressing what's no longer immediately relevant. This lets it stay sharp across long, multi-step tasks without blowing the context window.
**Tool Output Preview Budget**: Structured synopses of externalized results have a character budget. Long JSON keys and paths are shortened with `...`, and oversized synopsis bodies include a truncation notice. Raw head/tail samples, the full file reference, and `read_file` guidance are preserved; read the saved file for exact keys or complete output.
**Strict Tool-Call Recovery**: When a provider or middleware interrupts a tool-call loop, DeerFlow now strips provider-level raw tool-call metadata on forced-stop assistant messages and injects placeholder tool results for dangling calls before the next model invocation. This keeps OpenAI-compatible reasoning models that strictly validate `tool_call_id` sequences from failing with malformed history errors.
**Visible Tool-Run Completion**: For interactive turns, DeerFlow retries an empty post-tool final response once, then surfaces a visible error instead of reporting a silent successful run.

View File

@ -53,7 +53,8 @@ No match: skip inspection, log ID. Tests: `test_read_mark_tool_call_correlation.
1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings. Caller markers are marked `untrusted_input`, never stripped; scope is every turn.
**KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search` when disabled without reading storage or RAGFlow.
2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with configured limits and exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with a synopsis + `read_file` reference. Preserve media in mixed JSON results. Blob refs restore across hosts; hash/write failures use inline fallback. Internal outputs bypass workspace/delivery scans. Publication, write elision and bash exit marker: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision uses shared `tool_call_args`; retain `keep_recent_writes` newest writes. Controls: `elide_superseded_writes`, `superseded_write_min_chars`.
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; 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. Controls: `elide_superseded_writes`, `superseded_write_min_chars`.
Tests: `tests/test_tool_output_synopsis_budget.py`.
3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. `<system-reminder>`) 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` (neutralizes the raw output, then the budget truncates). 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
Result-rewriting middlewares between the raw callable boundary and the

View File

@ -22,6 +22,7 @@ ToolOutputKind = Literal["json", "csv", "tsv", "yaml", "xml", "code", "text", "u
_KEY_LIMIT = 12
_SCALAR_LIMIT = 6
_LABEL_CHARS = 80
_TABLE_SAMPLE_ROWS = 50
_TABLE_COLUMN_LIMIT = 18
_TEXT_HEADER_LIMIT = 16
@ -150,12 +151,12 @@ def render_tool_output_preview(
# appended (avoids duplicating head/tail bytes in both places).
if synopsis.kind == "text" and head_budget + tail_budget > 0 and len(content) > head_budget + tail_budget:
synopsis = _summarize_text(content, tool_name=tool_name, include_excerpts=False)
lines = [
tool_name = _one_line(tool_name, _LABEL_CHARS)
header = [
f"[Full {tool_name} output saved to {virtual_path} ({total} chars, ~{total // 4} tokens).]",
f"[Preview kind: {synopsis.kind}. This is a structured synopsis, not a raw head/tail truncation.]",
"",
f"{synopsis.title}:",
]
lines = [f"{synopsis.title}:"]
lines.extend(f"- {item}" for item in synopsis.summary)
if synopsis.structure:
@ -168,6 +169,15 @@ def render_tool_output_preview(
lines.append("Notable items:")
lines.extend(f"- {item}" for item in synopsis.notable_items)
# Reuse the head/tail budget, with a floor of two existing short excerpts.
# Keep the file reference and read guidance outside the synopsis budget.
synopsis_budget = max(_TEXT_EXCERPT_CHARS * 2, head_budget + tail_budget)
body = "\n".join(lines)
if len(body) > synopsis_budget:
marker = "\n[Synopsis truncated; use read_file for full output.]"
body = body[: synopsis_budget - len(marker)] + marker
lines = [*header, "", body]
raw_sample = _build_raw_sample(content, head_budget=head_budget, tail_budget=tail_budget, existing=synopsis.sample)
if raw_sample:
lines.append("")
@ -260,15 +270,15 @@ def _type_name(value: Any) -> str:
def _short_value(value: Any) -> str:
if isinstance(value, str):
return json.dumps(_clip(value, 80), ensure_ascii=False)
return _clip(repr(value), 80)
return json.dumps(_clip(value, _LABEL_CHARS), ensure_ascii=False)
return _clip(repr(value), _LABEL_CHARS)
def _json_shape(value: Any, *, depth: int = 0) -> str:
if depth >= _JSON_SHAPE_MAX_DEPTH:
return "..."
if isinstance(value, dict):
keys = [str(key) for key in list(value.keys())[:_KEY_LIMIT]]
keys = [_one_line(str(key), _LABEL_CHARS) for key in list(value.keys())[:_KEY_LIMIT]]
suffix = f": {', '.join(keys)}" if keys else ""
return f"object(keys={len(value)}{suffix})"
if isinstance(value, list):
@ -279,15 +289,15 @@ def _json_shape(value: Any, *, depth: int = 0) -> str:
def _json_path(parent: str, key: Any) -> str:
key_text = str(key)
key_text = _clip(str(key), _LABEL_CHARS)
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key_text):
return f"{parent}.{key_text}"
return f"{parent}[{json.dumps(key_text, ensure_ascii=False)}]"
return _clip(f"{parent}.{key_text}", _TEXT_EXCERPT_CHARS)
return _clip(f"{parent}[{json.dumps(key_text, ensure_ascii=False)}]", _TEXT_EXCERPT_CHARS)
def _json_container_description(value: Any) -> str:
if isinstance(value, dict):
keys = [str(key) for key in list(value.keys())[:_KEY_LIMIT]]
keys = [_one_line(str(key), _LABEL_CHARS) for key in list(value.keys())[:_KEY_LIMIT]]
suffix = f"; keys {', '.join(keys)}" if keys else ""
return f"object keys {len(value)}{suffix}"
if isinstance(value, list):
@ -325,7 +335,7 @@ def _json_container_paths(value: Any, *, limit: int = _JSON_STRUCTURE_LIMIT) ->
if isinstance(node, list) and node:
first = node[0]
if isinstance(first, (dict, list)):
walk(first, f"{current_path}[]", depth + 1)
walk(first, _clip(f"{current_path}[]", _TEXT_EXCERPT_CHARS), depth + 1)
walk(value, "$", 0)
return paths
@ -339,13 +349,13 @@ def _scalar_examples(value: Any, *, path: str = "$", limit: int = _SCALAR_LIMIT)
return
if isinstance(node, dict):
for key, child in list(node.items())[:_KEY_LIMIT]:
walk(child, f"{current}.{key}", depth + 1)
walk(child, _json_path(current, key), depth + 1)
if len(examples) >= limit:
break
return
if isinstance(node, list):
for index, child in enumerate(node[:2]):
walk(child, f"{current}[{index}]", depth + 1)
walk(child, _clip(f"{current}[{index}]", _TEXT_EXCERPT_CHARS), depth + 1)
if len(examples) >= limit:
break
return
@ -376,8 +386,8 @@ def _try_json(content: str) -> ToolOutputSynopsis | None:
# who relied on the old preview to only expose head/tail snippets should
# review their tool outputs for sensitive mid-document values.
if isinstance(value, dict):
keys = [str(key) for key in value.keys()]
summary.append(f"JSON object with {len(keys)} top-level keys.")
keys = [_one_line(str(key), _LABEL_CHARS) for key in list(value.keys())[:_KEY_LIMIT]]
summary.append(f"JSON object with {len(value)} top-level keys.")
summary.append(f"Top-level keys: {', '.join(keys[:_KEY_LIMIT]) or '(none)'}")
elif isinstance(value, list):
summary.append(f"JSON array with {len(value)} items.")

View File

@ -0,0 +1,151 @@
"""Verify structured preview budgets and access to the complete raw output."""
import json
from types import SimpleNamespace
import pytest
from langchain_core.messages import ToolMessage
from langgraph.types import Command
from deerflow.agents.middlewares import tool_output_synopsis as synopsis_module
from deerflow.agents.middlewares.tool_output_budget_middleware import ToolOutputBudgetMiddleware, _build_preview, _patch_model_messages
from deerflow.agents.middlewares.tool_output_synopsis import ToolOutputSynopsis, build_tool_output_synopsis
from deerflow.config.tool_output_config import ToolOutputConfig
@pytest.mark.parametrize("key", ["x" * 20000, "字段🙂" * 5000, '"\\\n' * 5000], ids=["ascii", "unicode", "escaped"])
@pytest.mark.parametrize("nested", [False, True])
def test_long_json_labels_and_paths_are_bounded(key, nested):
value = {key: {key: {key: 1}}} if nested else {key: 1}
content = json.dumps(value, ensure_ascii=False)
synopsis = build_tool_output_synopsis(content)
assert synopsis.kind == "json"
for item in synopsis.summary + synopsis.structure + synopsis.notable_items:
assert len(item) <= 1500
preview = _build_preview(content, tool_name="bash", virtual_path="/mnt/result.json", head_chars=1000, tail_chars=500)
assert len(preview) < 4000
assert "..." in preview
assert preview.endswith("Use read_file on /mnt/result.json with start_line and end_line to inspect the raw output.")
preview.encode("utf-8")
@pytest.mark.parametrize("head,tail", [(0, 0), (1, 0), (0, 1), (1000, 500), (2000, 1000)])
@pytest.mark.parametrize("kind", ["json", "xml", "csv", "yaml", "text", "unknown"])
def test_renderer_bounds_aggregate_synopsis_and_keeps_samples(monkeypatch, head, tail, kind):
# Check every format at the shared renderer so no parser bypasses the budget.
monkeypatch.setattr(synopsis_module, "build_tool_output_synopsis", lambda *args, **kwargs: ToolOutputSynopsis(kind, "title", ["s" * 10000], ["p" * 10000] * 24, ["v" * 10000] * 6))
content = "H" * 5000 + "T" * 5000
preview = _build_preview(content, tool_name="bash", virtual_path="/mnt/result.txt", head_chars=head, tail_chars=tail)
# Use two existing short excerpts as the synopsis floor; retain raw samples separately.
assert len(preview) <= max(840, head + tail) + head + tail + 600
if kind != "text" or head + tail == 0:
assert "Synopsis truncated" in preview
assert preview.endswith("Use read_file on /mnt/result.txt with start_line and end_line to inspect the raw output.")
if head:
assert "H" * head in preview
if tail:
assert "T" * tail in preview
@pytest.mark.parametrize("offset", [-1, 0, 1])
def test_synopsis_exact_budget_boundary(monkeypatch, offset):
prefix = "title:\n- "
summary = "中" * (840 - len(prefix) + offset)
monkeypatch.setattr(synopsis_module, "build_tool_output_synopsis", lambda *args, **kwargs: ToolOutputSynopsis("json", "title", [summary], [], []))
preview = _build_preview("{}", tool_name="bash", virtual_path="/mnt/result.txt", head_chars=0, tail_chars=0)
body = preview.split("\n\n", 1)[1].rsplit("\n\nAccess:", 1)[0]
assert len(body) <= 840
assert ("Synopsis truncated" in body) == (offset > 0)
if offset <= 0:
assert body == prefix + summary
@pytest.mark.parametrize("mode", ["sync", "async", "command"])
@pytest.mark.asyncio
async def test_model_visible_preview_preserves_externalized_json(tmp_path, mode):
content = json.dumps({"字段🙂" * 5000: {"child": 1}}, ensure_ascii=False)
config = ToolOutputConfig()
middleware = ToolOutputBudgetMiddleware(config=config)
request = SimpleNamespace(tool_call={"name": "bash", "id": "call-json"}, runtime=SimpleNamespace(state={"thread_data": {"outputs_path": str(tmp_path)}}))
message = ToolMessage(content=content, name="bash", tool_call_id="call-json")
result = Command(update={"messages": [message]}) if mode == "command" else message
if mode == "async":
async def handler(_):
return result
visible = await middleware.awrap_tool_call(request, handler)
else:
visible = middleware.wrap_tool_call(request, lambda _: result)
if mode == "command":
visible = visible.update["messages"][0]
assert len(visible.content) < 7000
assert "Preview kind: json" in visible.content
assert "read_file" in visible.content
files = list((tmp_path / ".tool-results").iterdir())
assert len(files) == 1
assert files[0].read_text(encoding="utf-8") == content
virtual_path = f"/mnt/user-data/outputs/.tool-results/{files[0].name}"
assert visible.content.count(virtual_path) == 2
assert _patch_model_messages([visible], config) is None
assert message.content == content
def test_short_json_keeps_complete_paths_and_values():
content = '{"meta":{"source":"unit"},"items":[{"id":1}]}'
preview = _build_preview(content, tool_name="bash", virtual_path="/mnt/result.json", head_chars=0, tail_chars=0)
assert "Top-level keys: meta, items" in preview
assert '$.meta.source: "unit"' in preview
assert "$.items[0].id: 1" in preview
assert "Synopsis truncated" not in preview
@pytest.mark.parametrize(
"kind,content",
[
("json", json.dumps({str(i): {"field" * 100: list(range(20))} for i in range(12)})),
("xml", "<" + "x" * 10000 + "/>"),
("yaml", "---\n" + "key" * 300 + ": 1\n"),
("csv", ("column" * 1000 + ",id\n") + "value,1\n" * 6),
("text", '{"invalid":' + "x" * 20000),
("unknown", "\x00" + "x" * 20000),
("unknown", "x" * 5000001),
],
ids=["json", "xml", "yaml", "csv", "invalid-json", "binary", "oversized"],
)
def test_actual_format_and_parse_fallback_stay_bounded(kind, content):
preview = _build_preview(content, tool_name="bash", virtual_path="/mnt/result.txt", head_chars=1000, tail_chars=500)
assert f"Preview kind: {kind}" in preview
assert len(preview) <= 3600
assert preview.endswith("Use read_file on /mnt/result.txt with start_line and end_line to inspect the raw output.")
@pytest.mark.parametrize("size", [79, 80, 81])
def test_json_label_boundary(size):
key = "k" * size
result = build_tool_output_synopsis(json.dumps({key: 1}))
keys = result.summary[1].removeprefix("Top-level keys: ")
assert len(keys) <= 80
assert (keys.endswith("...")) == (size > 80)
if size <= 80:
assert f"$.{key}: 1" in result.notable_items
def test_storage_failure_keeps_existing_fallback_budget():
content = json.dumps({"x" * 40000: 1})
config = ToolOutputConfig(fallback_max_chars=1000)
middleware = ToolOutputBudgetMiddleware(config=config)
request = SimpleNamespace(tool_call={"name": "bash", "id": "call-fallback"}, runtime=SimpleNamespace(state={}))
result = middleware.wrap_tool_call(request, lambda _: ToolMessage(content=content, name="bash", tool_call_id="call-fallback"))
assert len(result.content) <= 1000
assert "Persistent storage unavailable" in result.content
assert "output saved to" not in result.content
def test_long_reference_is_preserved_outside_synopsis_budget():
path = "/mnt/" + "目录/" * 200 + "result.json"
content = json.dumps({"x" * 20000: 1})
preview = _build_preview(content, tool_name="tool" * 10000, virtual_path=path, head_chars=0, tail_chars=0)
assert preview.count(path) == 2
assert len(preview) <= 840 + 2 * len(path) + 600
assert preview.endswith(f"Use read_file on {path} with start_line and end_line to inspect the raw output.")