fix(tools): resolve presented files with runtime user (#4677)

This commit is contained in:
Xinmin Zeng 2026-08-05 07:43:54 +08:00 committed by GitHub
parent d8ee55e5ac
commit 74692fec12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 111 additions and 4 deletions

View File

@ -373,7 +373,7 @@ collision-resistant directory-safe user IDs before accessing DeerFlow storage.
The default DeerFlow service topology remains the Gateway-embedded runtime
described above.
Gateway runs automatically enforce native delivery for artifacts created or modified under `/mnt/user-data/outputs`: `present_files` must present at least one output produced by the current run, and the terminal `run.delivery` receipt must be durably recorded. Runs that do not produce output artifacts keep ordinary conversational behavior.
Gateway runs automatically enforce native delivery for artifacts created or modified under `/mnt/user-data/outputs`: `present_files` must present at least one output produced by the current run, and the terminal `run.delivery` receipt must be durably recorded. Virtual artifact paths are resolved within the same authenticated user and thread scope that produced the output before the output-directory boundary is validated. Runs that do not produce output artifacts keep ordinary conversational behavior.
DeerFlow's built-in custom events are available through both LangGraph streaming interfaces: native clients can continue subscribing to `stream_mode="custom"`, while callback-based integrations can consume the same payloads as `on_custom_event` records from `astream_events(version="v2")`. The callback event name matches the payload's `type` field.

View File

@ -793,7 +793,7 @@ that cannot tell sibling branches apart.
1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()`
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with resolved-path + content-signature invalidation)
3. **Built-in tools**:
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`); virtual paths use `resolve_runtime_user_id(runtime)` so validation resolves the same user-scoped outputs directory established by `ThreadDataMiddleware`
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
- `view_image` - Read image as base64 (added only if model supports vision)
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.

View File

@ -7,7 +7,7 @@ from langgraph.config import get_config
from langgraph.types import Command
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.types import Runtime
OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
@ -66,7 +66,7 @@ def _normalize_presented_filepath(
if stripped == virtual_prefix or stripped.startswith(virtual_prefix + "/"):
try:
actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=get_effective_user_id())
actual_path = get_paths().resolve_virtual_path(thread_id, filepath, user_id=resolve_runtime_user_id(runtime))
except TypeError:
actual_path = get_paths().resolve_virtual_path(thread_id, filepath)
else:

View File

@ -3,6 +3,10 @@
import importlib
from types import SimpleNamespace
import pytest
from deerflow.config.paths import Paths
present_file_tool_module = importlib.import_module("deerflow.tools.builtins.present_file_tool")
@ -51,6 +55,34 @@ def test_present_files_keeps_virtual_outputs_path(tmp_path, monkeypatch):
assert result.update["artifacts"] == ["/mnt/user-data/outputs/summary.json"]
@pytest.mark.no_auto_user
def test_present_files_uses_runtime_user_for_virtual_outputs_path(tmp_path, monkeypatch):
"""A runtime user must resolve virtual output paths even without a request ContextVar."""
paths = Paths(tmp_path)
user_id = "runtime-user"
thread_id = "thread-runtime-user"
outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=user_id)
outputs_dir.mkdir(parents=True)
(outputs_dir / "report.md").write_text("ok")
monkeypatch.setattr(present_file_tool_module, "get_paths", lambda: paths)
runtime = SimpleNamespace(
state={"thread_data": {"outputs_path": str(outputs_dir)}},
context={"thread_id": thread_id, "user_id": user_id},
config={},
)
result = present_file_tool_module.present_file_tool.func(
runtime=runtime,
filepaths=["/mnt/user-data/outputs/report.md"],
tool_call_id="tc-runtime-user",
)
assert result.update["artifacts"] == ["/mnt/user-data/outputs/report.md"]
assert result.update["messages"][0].content == "Successfully presented files"
assert not paths.sandbox_outputs_dir(thread_id, user_id="default").exists()
def test_present_files_uses_config_thread_id_when_context_missing(tmp_path, monkeypatch):
outputs_dir = tmp_path / "threads" / "thread-from-config" / "user-data" / "outputs"
outputs_dir.mkdir(parents=True)

View File

@ -0,0 +1,75 @@
"""End-to-end user-isolation regression coverage for ``present_files``."""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from _agent_e2e_helpers import FakeToolCallingModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.runtime import Runtime
from deerflow.agents.factory import create_deerflow_agent
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
from deerflow.agents.thread_state import ThreadState
from deerflow.config.paths import Paths
from deerflow.tools.builtins.present_file_tool import present_file_tool
present_file_tool_module = importlib.import_module("deerflow.tools.builtins.present_file_tool")
def _build_present_files_graph(tmp_path: Path):
model = FakeToolCallingModel(
responses=[
AIMessage(
content="",
tool_calls=[
{
"name": "present_files",
"args": {"filepaths": ["/mnt/user-data/outputs/report.md"]},
"id": "call_present_file",
"type": "tool_call",
}
],
),
AIMessage(content="Presented the report."),
]
)
return create_deerflow_agent(
model,
tools=[present_file_tool],
middleware=[ThreadDataMiddleware(base_dir=str(tmp_path), lazy_init=True)],
state_schema=ThreadState,
system_prompt="Present the report file.",
)
@pytest.mark.no_auto_user
def test_present_files_uses_runtime_user_through_real_agent_graph(tmp_path, monkeypatch):
"""The real middleware and ToolNode must keep one user bucket without a ContextVar."""
paths = Paths(tmp_path)
user_id = "runtime-user"
thread_id = "thread-present-e2e"
outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=user_id)
outputs_dir.mkdir(parents=True)
(outputs_dir / "report.md").write_text("report body")
monkeypatch.setattr(present_file_tool_module, "get_paths", lambda: paths)
runtime = Runtime(context={"thread_id": thread_id, "user_id": user_id}, store=None)
config = {
"configurable": {"thread_id": thread_id, "__pregel_runtime": runtime},
"recursion_limit": 20,
}
final_state = _build_present_files_graph(tmp_path).invoke(
{"messages": [HumanMessage(content="Present the report")]},
config=config,
)
assert final_state["thread_data"]["outputs_path"] == str(outputs_dir)
tool_messages = [message for message in final_state["messages"] if isinstance(message, ToolMessage)]
assert len(tool_messages) == 1
assert tool_messages[0].content == "Successfully presented files"
assert final_state["artifacts"] == ["/mnt/user-data/outputs/report.md"]
assert not paths.sandbox_outputs_dir(thread_id, user_id="default").exists()