mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 09:08:38 +00:00
Document that read permission expiry and source deletion do not erase text already copied into the destination conversation, and that reads follow the source's current visible history. Truncated results now tell the agent to acknowledge the omission and ask for the missing material before claiming every requirement is covered. Pages were filled to 20,000 text characters by cutting the last message that did not fit, and that suffix could never be paged back. They could also exceed the default 12,000-character tool-output budget, which externalized the page to a file. Pages are now sized by their serialized length against the read_conversation tool-output budget; a message that does not fit starts the next page intact, so only a message over 4,000 characters (or one whose escaped JSON alone exceeds the budget) is cut. Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
"""The history tool uses only a host-provided, authorized reader."""
|
||
|
||
import json
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
from deerflow.config.tool_config import ToolConfig
|
||
from deerflow.tools.conversation import CONVERSATION_READER_CONTEXT_KEY, read_conversation
|
||
from deerflow.tools.tools import get_available_tools
|
||
from deerflow.tools.types import Runtime
|
||
|
||
|
||
def _config(*, configured=True, name="read_conversation"):
|
||
return SimpleNamespace(
|
||
tools=[ToolConfig(name=name, group="conversation", use="deerflow.tools.conversation:read_conversation")] if configured else [],
|
||
sandbox=SimpleNamespace(use="example.remote:Sandbox"),
|
||
skill_evolution=SimpleNamespace(enabled=False),
|
||
models=[],
|
||
acp_agents={},
|
||
get_model_config=lambda name: None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
"page",
|
||
[
|
||
'{"thread_id":"source","messages":[{"text":"Original answer"}],"next_cursor":"42"}',
|
||
'{"error":"Conversation unavailable"}',
|
||
],
|
||
)
|
||
async def test_read_conversation_forwards_only_page_arguments_to_host_reader(page):
|
||
reader = AsyncMock(return_value=page)
|
||
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader, "user_id": "unrelated", "allowed_threads": ["other"]})
|
||
|
||
result = await read_conversation.coroutine("source", runtime, cursor="50", limit=7)
|
||
|
||
assert result == page
|
||
reader.assert_awaited_once_with(thread_id="source", cursor="50", limit=7)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("context", [None, {}, {"__conversation_reader": "forged"}])
|
||
async def test_read_conversation_requires_callable_in_trusted_context(context):
|
||
other_reader = AsyncMock()
|
||
runtime = SimpleNamespace(context=context, config={"configurable": {CONVERSATION_READER_CONTEXT_KEY: other_reader}})
|
||
|
||
result = await read_conversation.coroutine("source", runtime)
|
||
|
||
assert "unavailable" in json.loads(result)["error"].lower()
|
||
other_reader.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_read_conversation_denies_subagent_even_with_reader():
|
||
reader = AsyncMock()
|
||
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader, "is_subagent": True})
|
||
|
||
result = await read_conversation.coroutine("source", runtime)
|
||
|
||
assert "subagent" in json.loads(result)["error"].lower()
|
||
reader.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
"arguments",
|
||
[
|
||
{"thread_id": "../other"},
|
||
{"limit": 0},
|
||
{"limit": 51},
|
||
{"limit": True},
|
||
{"cursor": "0"},
|
||
{"cursor": "-1"},
|
||
{"cursor": ""},
|
||
{"cursor": "1"},
|
||
{"cursor": 1},
|
||
],
|
||
)
|
||
async def test_invalid_page_arguments_do_not_reach_reader(arguments):
|
||
reader = AsyncMock()
|
||
runtime = SimpleNamespace(context={CONVERSATION_READER_CONTEXT_KEY: reader})
|
||
|
||
result = await read_conversation.coroutine(**{"thread_id": "source", "runtime": runtime, **arguments})
|
||
|
||
assert "error" in json.loads(result)
|
||
reader.assert_not_called()
|
||
|
||
|
||
def test_read_conversation_model_schema_has_no_identity_or_runtime_fields():
|
||
assert set(read_conversation.tool_call_schema.model_fields) == {"thread_id", "cursor", "limit"}
|
||
|
||
|
||
def test_tool_name_constant_matches_the_registered_tool():
|
||
# The Gateway sizes pages by this name's tool-output budget; a rename must move both.
|
||
from deerflow.constants import CONVERSATION_TOOL_NAME
|
||
|
||
assert read_conversation.name == CONVERSATION_TOOL_NAME
|
||
|
||
|
||
@pytest.mark.parametrize("name", ["read_conversation", "renamed_reader"])
|
||
def test_conversation_reader_is_not_loaded_by_default(monkeypatch, name):
|
||
monkeypatch.setattr("deerflow.tools.tools.resolve_variable", lambda *_: pytest.fail("disabled reader must not be imported"))
|
||
|
||
tools = get_available_tools(include_mcp=False, app_config=_config(name=name))
|
||
|
||
assert "read_conversation" not in {tool.name for tool in tools}
|
||
|
||
|
||
def test_conversation_reader_requires_both_configuration_and_host_opt_in():
|
||
enabled = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config())
|
||
unconfigured = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config(configured=False))
|
||
|
||
assert "read_conversation" in {tool.name for tool in enabled}
|
||
assert "read_conversation" not in {tool.name for tool in unconfigured}
|
||
|
||
|
||
def test_conversation_reader_still_respects_tool_group_filter():
|
||
tools = get_available_tools(groups=["other"], include_mcp=False, include_conversation_reader=True, app_config=_config())
|
||
|
||
assert "read_conversation" not in {tool.name for tool in tools}
|
||
|
||
|
||
def test_assembled_reader_supports_sync_tool_callers():
|
||
reader = AsyncMock(return_value='{"messages":[]}')
|
||
runtime = Runtime(
|
||
state={},
|
||
context={CONVERSATION_READER_CONTEXT_KEY: reader},
|
||
config={},
|
||
stream_writer=lambda _: None,
|
||
tools=[],
|
||
tool_call_id="call-1",
|
||
store=None,
|
||
)
|
||
tools = get_available_tools(include_mcp=False, include_conversation_reader=True, app_config=_config())
|
||
assembled = next(tool for tool in tools if tool.name == "read_conversation")
|
||
|
||
assert assembled.invoke({"thread_id": "source", "runtime": runtime}) == '{"messages":[]}'
|
||
reader.assert_awaited_once_with(thread_id="source", cursor=None, limit=20)
|