mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(artifacts): keep a delimited sandbox path whole (#7308)
* fix(artifacts): keep a delimited sandbox path whole The free-text artifact scan matched `/mnt/user-data/\S+`, so a tool that quoted a filename with spaces registered a handle for the prefix: `Created "/mnt/user-data/outputs/quarterly report.pdf" successfully.` became `/mnt/user-data/outputs/quarterly`, which resolves to nothing after compact or checkpoint reload. Quotes and inline backticks had the same failure. The scan now classifies each candidate by how it is delimited. A span opened by a matching quote or backtick on the same line is one value and keeps its spaces and punctuation; a span whose delimiter never closes on that line reports nothing, because a truncated prefix names a file that does not exist; a bare path keeps the conservative whitespace boundary it always had. Ordering is the text order, and the shared `seen` set still deduplicates against structured references. Pins the three delimiters, both unchanged behaviours (bare path boundary, punctuation outside the delimiter), the unterminated and next-line cases, two delimited paths in one result, a mixed delimited/bare result, and the extracted handle end to end. * fix(artifacts): reject line breaks and match escaped quotes * fix(artifacts): validate quoted path boundaries --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
6caa5addf3
commit
1c9e962b32
@ -960,7 +960,7 @@ already received by the browser, without an additional secret-redaction layer.
|
||||
|
||||
In plan mode, malformed TODO statuses return normal tool-validation errors without aborting token attribution, so the agent can correct the call.
|
||||
|
||||
Tool-produced paths and URLs can be retained as short artifact handles across context compaction (`tool_artifacts` in `config.yaml`). Handles distinguish separate tool-result occurrences, even when a provider reuses call IDs. Standard MCP image URLs are captured alongside file references, including results from stdio and HTTP servers. Detected file URLs preserve their query strings and fragments. Free-text artifact references retain balanced ASCII and CJK brackets in literal paths and URLs while trimming surrounding prose punctuation. When PII redaction is enabled, model-visible artifact labels follow that policy; internal references stay intact for tool argument resolution. The configured registry limit retains the newest artifacts, while checkpointed processing identities prevent evicted results from being recaptured after restart. Resolution runs before authorization and write-safety checks; unknown or expired handles return an error without executing the tool. Small unknown structured results may be retained as complete JSON up to 4096 UTF-8 bytes; empty or oversized payloads are skipped. Handles are agent-local: task arguments resolve parent handles to concrete references, and delegated reports must return concrete references rather than child-local handles. A truncated model projection reports how many handles are omitted.
|
||||
Tool-produced paths and URLs can be retained as short artifact handles across context compaction (`tool_artifacts` in `config.yaml`). Handles distinguish separate tool-result occurrences, even when a provider reuses call IDs. Standard MCP image URLs are captured alongside file references, including results from stdio and HTTP servers. Detected file URLs preserve their query strings and fragments. Free-text sandbox paths retain spaces inside matching ASCII quotes, backticks, or curly quote pairs, including backslash-escaped wrappers. Unterminated spans and delimited values containing a line break or tab are skipped. Free-text artifact references retain balanced ASCII and CJK brackets in literal paths and URLs while trimming surrounding prose punctuation. When PII redaction is enabled, model-visible artifact labels follow that policy; internal references stay intact for tool argument resolution. The configured registry limit retains the newest artifacts, while checkpointed processing identities prevent evicted results from being recaptured after restart. Resolution runs before authorization and write-safety checks; unknown or expired handles return an error without executing the tool. Small unknown structured results may be retained as complete JSON up to 4096 UTF-8 bytes; empty or oversized payloads are skipped. Handles are agent-local: task arguments resolve parent handles to concrete references, and delegated reports must return concrete references rather than child-local handles. A truncated model projection reports how many handles are omitted.
|
||||
|
||||
DeerFlow supports configurable MCP servers and skills to extend its capabilities.
|
||||
When durable MCP background tasks are enabled, agents can use `list_background_tasks(status="failed")` or `status="input_required"` to find failed tasks or tasks awaiting input in the current chat. Other supported statuses are `submitted`, `working`, `completed`, and `cancelled`; omitting the status preserves existing behavior. The database applies the filter before limiting results to the 20 most recent matching tasks. When combined with `active_only=true`, both filters apply: active statuses are `submitted`, `working`, and `input_required`, so terminal statuses return an empty list. `GET /api/threads/{thread_id}/mcp-tasks` supports the same `status` and `active_only` filters before its `limit` (default 50, range 1–100); for example, `?status=failed&limit=20`. Unknown statuses return 422, and omitting the filters preserves the existing response.
|
||||
|
||||
@ -5,6 +5,12 @@ legacy `source.url`/`source.mime_type`. A present top-level `url` selects that
|
||||
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`.
|
||||
|
||||
`artifact_registry.py` checks remote file suffixes against the complete parsed
|
||||
URL path without case sensitivity. Free-text URL and sandbox-path extraction
|
||||
strips common trailing ASCII/CJK prose punctuation while preserving ASCII
|
||||
|
||||
@ -28,8 +28,18 @@ _HANDLE_LENGTH = 8
|
||||
|
||||
# Virtual sandbox path prefix (used by local stdio MCP servers whose files live
|
||||
# inside the mounted user-data tree).
|
||||
_SANDBOX_PATH_PREFIX = "/mnt/user-data/"
|
||||
_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_LINE_BOUNDARY_PATTERN = re.compile(r"[\r\n\v\f\x1c-\x1e\x85\u2028\u2029]")
|
||||
_SPAN_BARE = "bare"
|
||||
_SPAN_QUOTED = "quoted"
|
||||
_SPAN_UNTERMINATED = "unterminated"
|
||||
|
||||
# Scan complete URLs before checking the path, so extensions in query strings
|
||||
# cannot truncate a reference or turn a non-file URL into a file artifact.
|
||||
_REMOTE_URL_PATTERN = re.compile(r"https?://[^\s\"'`<>]+")
|
||||
@ -148,20 +158,78 @@ def _strip_ref_trailing_noise(raw: str) -> str:
|
||||
return raw[:end]
|
||||
|
||||
|
||||
def _classify_span(text: str, start: int) -> tuple[str, int | None]:
|
||||
"""How the sandbox path at ``start`` is delimited, and where a quoted span ends.
|
||||
|
||||
``bare`` paths keep the whitespace boundary. A ``quoted`` span ends at the matching
|
||||
delimiter on the same line, which is what keeps ``"report final.pdf"`` whole;
|
||||
``unterminated`` is a span whose delimiter never closes on that line, and it produces
|
||||
no reference at all rather than a prefix of a filename that does not exist.
|
||||
"""
|
||||
opener = text[start - 1] if start else ""
|
||||
if opener not in _REF_QUOTE_DELIMITERS:
|
||||
return _SPAN_BARE, None
|
||||
line_break = _REF_LINE_BOUNDARY_PATTERN.search(text, start)
|
||||
line_end = line_break.start() if line_break else len(text)
|
||||
# Odd backslash runs escape the wrapper; even runs leave it unescaped.
|
||||
# Exclude only the wrapper marker, without decoding backslashes in the path.
|
||||
escape_start = start - 1
|
||||
while escape_start and text[escape_start - 1] == "\\":
|
||||
escape_start -= 1
|
||||
escape_prefix = text[escape_start : start - 1]
|
||||
closer_marker = (escape_prefix if len(escape_prefix) % 2 else "") + _REF_QUOTE_DELIMITERS[opener]
|
||||
closer = text.find(closer_marker, start, line_end)
|
||||
if closer == -1:
|
||||
return _SPAN_UNTERMINATED, None
|
||||
return _SPAN_QUOTED, closer
|
||||
|
||||
|
||||
def _is_referenceable_sandbox_path(raw: str) -> bool:
|
||||
"""A sandbox path may contain spaces only when the tool delimited it.
|
||||
|
||||
The size and single-line bounds still apply: a delimited span can carry prose, and an
|
||||
unbounded or multi-line value must not become a handle.
|
||||
"""
|
||||
if not raw.startswith(_SANDBOX_PATH_PREFIX) or len(raw) == len(_SANDBOX_PATH_PREFIX):
|
||||
return False
|
||||
if _REF_LINE_BOUNDARY_PATTERN.search(raw) or "\t" in raw or raw != raw.strip():
|
||||
return False
|
||||
try:
|
||||
return len(raw.encode("utf-8")) <= _STRUCTURED_DATA_MAX_BYTES
|
||||
except UnicodeError:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_refs_in_text(text: str) -> list[dict[str, str]]:
|
||||
"""Conservatively detect file paths and remote file URLs in free text."""
|
||||
refs: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for match in _SANDBOX_PATH_PATTERN.finditer(text):
|
||||
raw = _strip_ref_trailing_noise(match.group(0))
|
||||
if raw in seen or not _is_referenceable_url(raw):
|
||||
cursor = 0
|
||||
while (start := text.find(_SANDBOX_PATH_PREFIX, cursor)) != -1:
|
||||
cursor = start + 1
|
||||
kind, span_end = _classify_span(text, start)
|
||||
if kind == _SPAN_UNTERMINATED:
|
||||
# The opener says the path runs past this point or onto another line; a
|
||||
# whitespace-truncated prefix would name a file that does not exist.
|
||||
continue
|
||||
if kind == _SPAN_QUOTED:
|
||||
assert span_end is not None
|
||||
raw = text[start:span_end]
|
||||
cursor = span_end
|
||||
else:
|
||||
match = _SANDBOX_PATH_PATTERN.match(text, start)
|
||||
if match is None:
|
||||
continue
|
||||
cursor = match.end()
|
||||
raw = _strip_ref_trailing_noise(match.group(0))
|
||||
if raw in seen or not _is_referenceable_sandbox_path(raw):
|
||||
continue
|
||||
seen.add(raw)
|
||||
refs.append(
|
||||
{
|
||||
"type": "file",
|
||||
"ref": raw,
|
||||
"display": raw.split("/")[-1],
|
||||
"display": _display_name_for_ref(raw),
|
||||
}
|
||||
)
|
||||
for match in _REMOTE_URL_PATTERN.finditer(text):
|
||||
|
||||
@ -673,3 +673,226 @@ def test_merge_tool_artifacts_consumption_update_does_not_grow_count():
|
||||
consumed = {**entries[0], "consumed_by": ["call-x"]}
|
||||
merged = merge_tool_artifacts(entries, [consumed])
|
||||
assert len(merged) == 1000
|
||||
|
||||
|
||||
# --- delimited sandbox paths (issue #7303) ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
|
||||
def test_a_delimited_sandbox_path_keeps_its_spaces(delimiter):
|
||||
"""A tool that quotes the filename means the whole quoted string is the path."""
|
||||
path = "/mnt/user-data/outputs/quarterly report (final).pdf"
|
||||
|
||||
refs = _detect_refs_in_text(f"Created {delimiter}{path}{delimiter} successfully.")
|
||||
|
||||
assert [ref["ref"] for ref in refs] == [path]
|
||||
assert refs[0]["display"] == "quarterly report (final).pdf"
|
||||
|
||||
|
||||
def test_a_bare_sandbox_path_keeps_the_whitespace_boundary():
|
||||
"""Nothing tells the free-text scan where an undelimited name with spaces ends, so it
|
||||
keeps the conservative boundary it always had."""
|
||||
refs = _detect_refs_in_text("Created /mnt/user-data/outputs/quarterly report.pdf successfully.")
|
||||
|
||||
assert [ref["ref"] for ref in refs] == ["/mnt/user-data/outputs/quarterly"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
# Never closed on this line.
|
||||
'Created "/mnt/user-data/outputs/quarterly report.pdf successfully.',
|
||||
"Created '/mnt/user-data/outputs/quarterly report.pdf",
|
||||
"Created `/mnt/user-data/outputs/quarterly report.pdf",
|
||||
# Closed, but only after the line break: a multi-line value is not one path.
|
||||
'Created "/mnt/user-data/outputs/quarterly report.pdf\nsuccessfully."',
|
||||
],
|
||||
)
|
||||
def test_an_unterminated_delimited_span_produces_no_reference(text):
|
||||
"""A prefix of a quoted filename names a file that does not exist, so it is not
|
||||
reported at all rather than reported truncated."""
|
||||
assert _detect_refs_in_text(text) == []
|
||||
|
||||
|
||||
def test_delimited_paths_keep_their_order_and_deduplicate():
|
||||
first = "/mnt/user-data/outputs/a report.pdf"
|
||||
second = "/mnt/user-data/outputs/b report.csv"
|
||||
|
||||
refs = _detect_refs_in_text(f'Published `{first}` and "{second}", then again "{first}".')
|
||||
|
||||
assert [ref["ref"] for ref in refs] == [first, second]
|
||||
|
||||
|
||||
def test_a_delimited_and_a_bare_path_in_one_result_are_both_captured():
|
||||
quoted = "/mnt/user-data/outputs/a report.pdf"
|
||||
|
||||
refs = _detect_refs_in_text(f'Copied to "{quoted}"; log at /mnt/user-data/workspace/run.log.')
|
||||
|
||||
assert [ref["ref"] for ref in refs] == [quoted, "/mnt/user-data/workspace/run.log"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
('Output: "/mnt/user-data/outputs/report.pdf".', "/mnt/user-data/outputs/report.pdf"),
|
||||
("Output: `/mnt/user-data/outputs/report.pdf`,", "/mnt/user-data/outputs/report.pdf"),
|
||||
],
|
||||
)
|
||||
def test_punctuation_outside_the_delimiter_is_not_part_of_the_reference(text, expected):
|
||||
assert [ref["ref"] for ref in _detect_refs_in_text(text)] == [expected]
|
||||
|
||||
|
||||
def test_extraction_registers_the_full_delimited_path_once():
|
||||
path = "/mnt/user-data/outputs/quarterly report.pdf"
|
||||
message = ToolMessage(
|
||||
content=f'Created "{path}" successfully.',
|
||||
tool_call_id="export-1",
|
||||
name="export_report",
|
||||
)
|
||||
|
||||
entries = extract_artifacts_from_result(message, thread_id="demo")
|
||||
|
||||
assert [entry["real_ref"] for entry in entries] == [path]
|
||||
assert entries[0]["display_name"] == "quarterly report.pdf"
|
||||
assert entries[0]["artifact_type"] == "file"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", ["\n", "\r", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029", "\t"])
|
||||
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
|
||||
def test_delimited_sandbox_path_rejects_line_breaks_and_tabs(separator, delimiter):
|
||||
message = ToolMessage(
|
||||
content=f"Created {delimiter}/mnt/user-data/outputs/quarterly{separator}report.pdf{delimiter}.",
|
||||
tool_call_id="export-multiline",
|
||||
name="export_report",
|
||||
)
|
||||
|
||||
assert extract_artifacts_from_result(message, thread_id="demo") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
|
||||
@pytest.mark.parametrize("content_block", [False, True])
|
||||
def test_backslash_escaped_delimiters_keep_the_full_sandbox_path(delimiter, content_block):
|
||||
path = "/mnt/user-data/outputs/quarterly report (final).pdf"
|
||||
text = f"log: \\{delimiter}{path}\\{delimiter} done"
|
||||
message = ToolMessage(
|
||||
content=[{"type": "text", "text": text}] if content_block else text,
|
||||
tool_call_id="export-escaped",
|
||||
name="export_report",
|
||||
)
|
||||
|
||||
entries = extract_artifacts_from_result(message, thread_id="demo")
|
||||
|
||||
assert [entry["real_ref"] for entry in entries] == [path]
|
||||
assert entries[0]["display_name"] == "quarterly report (final).pdf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("escaped", [False, True])
|
||||
def test_delimited_sandbox_path_keeps_a_literal_trailing_backslash(escaped):
|
||||
path = "/mnt/user-data/outputs/report \\"
|
||||
delimiter = '\\"' if escaped else '"'
|
||||
|
||||
assert [ref["ref"] for ref in _detect_refs_in_text(f"Created {delimiter}{path}{delimiter}.")] == [path]
|
||||
|
||||
|
||||
def test_json_escaped_path_deduplicates_against_a_structured_reference():
|
||||
path = "/mnt/user-data/outputs/quarterly report.pdf"
|
||||
message = ToolMessage(
|
||||
content=json.dumps(f'Created "{path}" successfully.'),
|
||||
tool_call_id="export-json",
|
||||
name="export_report",
|
||||
artifact={"structured_content": {"path": path}},
|
||||
)
|
||||
|
||||
entries = extract_artifacts_from_result(message, thread_id="demo")
|
||||
|
||||
assert [entry["real_ref"] for entry in entries] == [path]
|
||||
|
||||
|
||||
def test_escaped_sandbox_path_requires_a_matching_escaped_closer():
|
||||
assert _detect_refs_in_text(r'Created \"/mnt/user-data/outputs/quarterly report.pdf" successfully.') == []
|
||||
|
||||
|
||||
_LINE_BREAKS = ["\n", "\r", "\r\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"]
|
||||
_CJK_QUOTE_PAIRS = [("“", "”"), ("‘", "’")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", [*_LINE_BREAKS, "\t"])
|
||||
@pytest.mark.parametrize(("opener", "closer"), _CJK_QUOTE_PAIRS)
|
||||
def test_delimited_sandbox_paths_reject_line_breaks_and_tabs(separator, opener, closer):
|
||||
path = f"/mnt/user-data/outputs/quarterly{separator}report.pdf"
|
||||
|
||||
assert _detect_refs_in_text(f"Created {opener}{path}{closer}.") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", _LINE_BREAKS)
|
||||
def test_unterminated_path_does_not_swallow_a_reference_on_the_next_line(separator):
|
||||
valid = "/mnt/user-data/outputs/next.pdf"
|
||||
text = f'Created "/mnt/user-data/outputs/unfinished report.pdf{separator}Saved {valid}"'
|
||||
|
||||
assert [ref["ref"] for ref in _detect_refs_in_text(text)] == [valid]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delimiter", ['"', "'", "`"])
|
||||
@pytest.mark.parametrize("escape_count", [3])
|
||||
@pytest.mark.parametrize(("path", "display"), [("/mnt/user-data/outputs/a b.pdf", "a b.pdf"), (r"/mnt/user-data/outputs/folder\name report.pdf", "name report.pdf")])
|
||||
def test_escaped_quote_wrappers_do_not_append_backslashes(delimiter, escape_count, path, display):
|
||||
marker = "\\" * escape_count + delimiter
|
||||
|
||||
refs = _detect_refs_in_text(f"log: {marker}{path}{marker} done")
|
||||
|
||||
assert [ref["ref"] for ref in refs] == [path]
|
||||
assert refs[0]["display"] == display
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("opener", "closer"), [("“", "”"), ("‘", "’")])
|
||||
def test_cjk_quote_pairs_keep_the_entire_sandbox_path(opener, closer):
|
||||
path = "/mnt/user-data/outputs/输出 报告(最终).pdf"
|
||||
|
||||
refs = _detect_refs_in_text(f"已创建 {opener}{path}{closer},完成。")
|
||||
|
||||
assert [ref["ref"] for ref in refs] == [path]
|
||||
assert refs[0]["display"] == "输出 报告(最终).pdf"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("opener", ["“", "‘"])
|
||||
def test_unterminated_cjk_quotes_do_not_register_prefixes(opener):
|
||||
assert _detect_refs_in_text(f"已创建 {opener}/mnt/user-data/outputs/输出 报告.pdf") == []
|
||||
|
||||
|
||||
def test_even_backslashes_before_a_quote_preserve_literal_path_backslashes():
|
||||
prefix = "\\" * 2
|
||||
path = "/mnt/user-data/outputs/a b\\"
|
||||
|
||||
assert [ref["ref"] for ref in _detect_refs_in_text(f'{prefix}"{path}"')] == [path]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("opener", "closer"), [('\\"', '\\"'), ("“", "”"), ("‘", "’")])
|
||||
@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"
|
||||
second = "/mnt/user-data/outputs/second report.csv"
|
||||
text = f"Created {opener}{first}{closer}, then {opener}{second}{closer}, then {opener}{first}{closer}."
|
||||
message = ToolMessage(
|
||||
content=[{"type": "text", "text": text}] if text_block else text,
|
||||
artifact={"structured_content": {"path": first}},
|
||||
tool_call_id="export-review",
|
||||
name="export_report",
|
||||
)
|
||||
|
||||
entries = extract_artifacts_from_result(message, thread_id="demo")
|
||||
|
||||
assert [entry["real_ref"] for entry in entries] == [first, second]
|
||||
assert [entry["display_name"] for entry in entries] == ["输出 报告.pdf", "second report.csv"]
|
||||
assert len({entry["handle"] for entry in entries}) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text_block", [False, True])
|
||||
def test_raw_json_text_preserves_a_quoted_sandbox_path(text_block):
|
||||
path = "/mnt/user-data/outputs/quarterly report.pdf"
|
||||
text = json.dumps({"message": f'Created "{path}" successfully.'})
|
||||
message = ToolMessage(content=[{"type": "text", "text": text}] if text_block else text, tool_call_id="export-json", name="export_report")
|
||||
|
||||
entries = extract_artifacts_from_result(message, thread_id="demo")
|
||||
|
||||
assert [entry["real_ref"] for entry in entries] == [path]
|
||||
assert entries[0]["display_name"] == "quarterly report.pdf"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user