fix(artifacts): keep sandbox paths inside CJK corner brackets whole (#7377)

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.
This commit is contained in:
Aling33 2026-10-24 20:45:02 +08:00 committed by GitHub
parent 74d2565f64
commit 3fdd9bdfff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 56 additions and 12 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"