From 3fdd9bdfff1b2ecffa8d45698db6cd914af79915 Mon Sep 17 00:00:00 2001 From: Aling33 <152494711+Aling33@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:45:02 +0800 Subject: [PATCH] fix(artifacts): keep sandbox paths inside CJK corner brackets whole (#7377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote-delimiter tables only knew ASCII quotes, backticks and curly quotes, so `已生成「/mnt/user-data/outputs/季度 报告.pdf」。` captured the handle `/mnt/user-data/outputs/季度` (the path pattern stops at the space) and `已生成『…』。` kept the closer inside `real_ref`; either handle is dead on a later read. Register `「」` and `『』` as wrappers: a paired closer stays inside a filename, an unpaired one is trimmed from a bare token, and corner-trailed URLs and refs are no longer cut. The persistence regression (capture -> registry round-trip -> resolution -> real file read with the tool result gone) is ported from #7376 by @yangjj-iso, per review. --- .../packages/harness/deerflow/tools/AGENTS.md | 8 ++-- .../deerflow/tools/artifact_registry.py | 6 +-- backend/tests/test_artifact_integration.py | 39 ++++++++++++++++++- backend/tests/test_artifact_registry.py | 15 +++++-- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 8161c8edfb..417a4fef69 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -6,10 +6,10 @@ shape, even when invalid; never mix its MIME metadata with a conflicting legacy source. Both shapes use the existing referenceability gate and per-result deduplication. Free-text sandbox paths retain spaces inside matching ASCII/curly quote pairs, -backticks or backslash-escaped wrappers. Exclude wrapper escapes without stripping -literal path backslashes. Reject Python line boundaries and tabs; bound delimiter -searches to the same line so later refs survive. Preserve bare-token boundaries -and structured refs. Tests: `tests/test_artifact_registry.py`. +CJK corner brackets, backticks or backslash-escaped wrappers. Exclude wrapper +escapes without stripping literal path backslashes. Reject Python line boundaries +and tabs; bound delimiter searches to the same line so later refs survive. +Preserve bare-token boundaries and structured refs. Tests: `tests/test_artifact_registry.py`. `artifact_registry.py` checks remote file suffixes against the complete parsed URL path without case sensitivity. Free-text URL and sandbox-path extraction diff --git a/backend/packages/harness/deerflow/tools/artifact_registry.py b/backend/packages/harness/deerflow/tools/artifact_registry.py index 76dff4d3d2..f44a0c1393 100644 --- a/backend/packages/harness/deerflow/tools/artifact_registry.py +++ b/backend/packages/harness/deerflow/tools/artifact_registry.py @@ -34,7 +34,7 @@ _SANDBOX_PATH_PATTERN = re.compile(r"/mnt/user-data/\S+") # Delimiters a tool result may wrap a path in. A delimited span is one value, so a # filename inside it keeps its spaces; the bare token scan below keeps its # whitespace boundary, because nothing tells it where such a name would end. -_REF_QUOTE_DELIMITERS = {'"': '"', "'": "'", "`": "`", "“": "”", "‘": "’"} +_REF_QUOTE_DELIMITERS = {'"': '"', "'": "'", "`": "`", "“": "”", "‘": "’", "「": "」", "『": "』"} _REF_LINE_BOUNDARY_PATTERN = re.compile(r"[\r\n\v\f\x1c-\x1e\x85\u2028\u2029]") _SPAN_BARE = "bare" _SPAN_QUOTED = "quoted" @@ -57,8 +57,8 @@ _STRUCTURED_TASK_KEYS = frozenset({"task_id", "job_id"}) # quotes/brackets/backticks that markdown- and JSON-formatted tool output # commonly wraps paths in, including CJK punctuation. `\S+` would otherwise # consume them into `real_ref`. -_REF_TRAILING_NOISE_CHARS = ".,;:)]}\"'`\u3002\uff0c\uff1b\uff1a\u3001\uff09\u3011\u300b\u201d\u2019" -_REF_CLOSING_DELIMITERS = {")": "(", "]": "[", "}": "{", ")": "(", "】": "【", "》": "《", "”": "“", "’": "‘"} +_REF_TRAILING_NOISE_CHARS = ".,;:)]}\"'`\u3002\uff0c\uff1b\uff1a\u3001\uff09\u3011\u300b\u300d\u300f\u201d\u2019" +_REF_CLOSING_DELIMITERS = {")": "(", "]": "[", "}": "{", ")": "(", "】": "【", "》": "《", "」": "「", "』": "『", "”": "“", "’": "‘"} # Content-block and structured-key refs are trusted only in these shapes. # `data:`/`blob:` URIs can carry arbitrarily large embedded payloads (MCP diff --git a/backend/tests/test_artifact_integration.py b/backend/tests/test_artifact_integration.py index 06633640d3..afd0c2c513 100644 --- a/backend/tests/test_artifact_integration.py +++ b/backend/tests/test_artifact_integration.py @@ -5,16 +5,20 @@ context -> model references the handle in a later tool call -> resolution rewrites it to the real reference at the tool-call boundary. """ +import json import re +from pathlib import PurePosixPath +from types import SimpleNamespace from typing import Any import httpx import pytest from _agent_e2e_helpers import FakeToolCallingModel from langchain.agents import create_agent -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.tools import tool from langgraph.checkpoint.memory import InMemorySaver +from langgraph.prebuilt.tool_node import ToolCallRequest from deerflow.agents.middlewares.artifact_capture_middleware import ArtifactCaptureMiddleware from deerflow.agents.middlewares.artifact_resolution_middleware import ArtifactResolutionMiddleware @@ -223,3 +227,36 @@ def test_glob_directory_survives_capture_checkpoint_and_resolved_listing(tmp_pat assert found["consumed_by"] == ["call_ls"] persisted = next(entry for entry in agent.get_state(config).values["tool_artifacts"] if entry["tool_call_id"] == "call_glob") assert persisted["real_ref"] == real_ref + + +@pytest.mark.parametrize(("opener", "closer"), [("「", "」"), ("『", "』")]) +def test_corner_quoted_artifact_resolves_after_source_message_is_gone(tmp_path, opener, closer): + filename = "季度 报告.txt" + (tmp_path / filename).write_text("report contents", encoding="utf-8") + real_ref = f"/mnt/user-data/outputs/{filename}" + runtime = SimpleNamespace(context={"thread_id": THREAD_ID}) + source = ToolMessage(id="corner-report", content=f"已生成{opener}{real_ref}{closer}。", tool_call_id="call_make", name="make_file") + update = ArtifactCaptureMiddleware().before_model({"messages": [source]}, runtime) + assert update is not None + # Restore only the persisted registry: the tool result may have been compacted away. + registry = json.loads(json.dumps(update["tool_artifacts"])) + assert len(registry) == 1 + request = ToolCallRequest( + tool_call={"name": "read_file", "args": {"path": registry[0]["handle"]}, "id": "call_read", "type": "tool_call"}, + tool=None, + state={"messages": [], "tool_artifacts": registry}, + runtime=runtime, + ) + reads = [] + + def read_report(resolved): + path = resolved.tool_call["args"]["path"] + reads.append(path) + relative = PurePosixPath(path).relative_to("/mnt/user-data/outputs") + return ToolMessage(content=(tmp_path / str(relative)).read_text(encoding="utf-8"), tool_call_id="call_read") + + result = ArtifactResolutionMiddleware().wrap_tool_call(request, read_report) + + assert reads == [real_ref] + assert result.content == "report contents" + assert registry[0]["display_name"] == filename diff --git a/backend/tests/test_artifact_registry.py b/backend/tests/test_artifact_registry.py index 741ed5f551..b41438761f 100644 --- a/backend/tests/test_artifact_registry.py +++ b/backend/tests/test_artifact_registry.py @@ -813,7 +813,7 @@ def test_escaped_sandbox_path_requires_a_matching_escaped_closer(): _LINE_BREAKS = ["\n", "\r", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"] -_CJK_QUOTE_PAIRS = [("“", "”"), ("‘", "’")] +_CJK_QUOTE_PAIRS = [("“", "”"), ("‘", "’"), ("「", "」"), ("『", "』")] @pytest.mark.parametrize("separator", [*_LINE_BREAKS, "\t"]) @@ -844,7 +844,7 @@ def test_escaped_quote_wrappers_do_not_append_backslashes(delimiter, escape_coun assert refs[0]["display"] == display -@pytest.mark.parametrize(("opener", "closer"), [("“", "”"), ("‘", "’")]) +@pytest.mark.parametrize(("opener", "closer"), _CJK_QUOTE_PAIRS) def test_cjk_quote_pairs_keep_the_entire_sandbox_path(opener, closer): path = "/mnt/user-data/outputs/输出 报告(最终).pdf" @@ -854,11 +854,18 @@ def test_cjk_quote_pairs_keep_the_entire_sandbox_path(opener, closer): assert refs[0]["display"] == "输出 报告(最终).pdf" -@pytest.mark.parametrize("opener", ["“", "‘"]) +@pytest.mark.parametrize("opener", [opener for opener, _ in _CJK_QUOTE_PAIRS]) def test_unterminated_cjk_quotes_do_not_register_prefixes(opener): assert _detect_refs_in_text(f"已创建 {opener}/mnt/user-data/outputs/输出 报告.pdf") == [] +@pytest.mark.parametrize("closer", [closer for _, closer in _CJK_QUOTE_PAIRS[-2:]]) +def test_a_bare_path_drops_an_unpaired_corner_closer(closer): + path = "/mnt/user-data/outputs/报告.pdf" + + assert [ref["ref"] for ref in _detect_refs_in_text(f"已生成 {path}{closer},")] == [path] + + def test_even_backslashes_before_a_quote_preserve_literal_path_backslashes(): prefix = "\\" * 2 path = "/mnt/user-data/outputs/a b\\" @@ -866,7 +873,7 @@ def test_even_backslashes_before_a_quote_preserve_literal_path_backslashes(): assert [ref["ref"] for ref in _detect_refs_in_text(f'{prefix}"{path}"')] == [path] -@pytest.mark.parametrize(("opener", "closer"), [('\\"', '\\"'), ("“", "”"), ("‘", "’")]) +@pytest.mark.parametrize(("opener", "closer"), [('\\"', '\\"'), *_CJK_QUOTE_PAIRS]) @pytest.mark.parametrize("text_block", [False, True]) def test_review_quote_wrappers_deduplicate_structured_refs_and_keep_order(opener, closer, text_block): first = "/mnt/user-data/outputs/输出 报告.pdf"