mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-08 05:48:53 +00:00
fix(tools): resolve presented files with runtime user (#4677)
This commit is contained in:
parent
d8ee55e5ac
commit
74692fec12
@ -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.
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
75
backend/tests/test_present_files_e2e_user_isolation.py
Normal file
75
backend/tests/test_present_files_e2e_user_isolation.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user