mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-12 06:59:07 +00:00
* feat(subagents): add capacity controls and durable batches * fix(helm): sync subagent config schema version * fix(subagents): preserve batch history without worker * fix(subagents): support explicit factory runtimes * fix: address durable batch review findings
201 lines
6.2 KiB
Python
201 lines
6.2 KiB
Python
import importlib
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from langchain_core.messages import ToolMessage
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.subagents.config import SubagentConfig
|
|
from deerflow.tools.builtins.batch_task_tool import BatchTaskItem
|
|
|
|
tool_module = importlib.import_module("deerflow.tools.builtins.batch_task_tool")
|
|
|
|
|
|
def _runtime():
|
|
return SimpleNamespace(
|
|
state={},
|
|
context={
|
|
"thread_id": "thread-1",
|
|
"run_id": "run-1",
|
|
"user_id": "user-1",
|
|
"user_role": "member",
|
|
},
|
|
config={
|
|
"metadata": {
|
|
"model_name": "model-a",
|
|
"allowed_subagents": ["general-purpose"],
|
|
"tool_groups": ["web"],
|
|
},
|
|
"configurable": {"thread_id": "thread-1"},
|
|
},
|
|
)
|
|
|
|
|
|
def _message(command: Command) -> ToolMessage:
|
|
messages = command.update["messages"]
|
|
assert len(messages) == 1 and isinstance(messages[0], ToolMessage)
|
|
return messages[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_task_is_explicit_idempotent_submission(monkeypatch) -> None:
|
|
submitter = AsyncMock()
|
|
submitter.submit.return_value = {
|
|
"id": "subagent-batch-1",
|
|
"status": "queued",
|
|
"total_items": 2,
|
|
}
|
|
monkeypatch.setattr(tool_module, "get_subagent_batch_submitter", lambda: submitter)
|
|
monkeypatch.setattr(
|
|
tool_module,
|
|
"get_available_subagent_names",
|
|
lambda **_kwargs: ["general-purpose"],
|
|
)
|
|
monkeypatch.setattr(
|
|
tool_module,
|
|
"get_subagent_config",
|
|
lambda *_args, **_kwargs: SubagentConfig(
|
|
name="general-purpose",
|
|
description="General purpose",
|
|
),
|
|
)
|
|
|
|
command = await tool_module.batch_task.coroutine(
|
|
runtime=_runtime(),
|
|
title="Process records",
|
|
items=[
|
|
BatchTaskItem(key="record-1", prompt="Process one"),
|
|
BatchTaskItem(key="record-2", prompt="Process two"),
|
|
],
|
|
subagent_type="general-purpose",
|
|
tool_call_id="call-1",
|
|
max_live_items=20,
|
|
max_running_items=5,
|
|
)
|
|
|
|
message = _message(command)
|
|
request = submitter.submit.await_args.args[0]
|
|
assert request.submission_key == "run-1:call-1"
|
|
assert request.user_id == "user-1"
|
|
assert [item["key"] for item in request.items] == ["record-1", "record-2"]
|
|
assert request.max_live_items == 20
|
|
assert request.max_running_items == 5
|
|
assert message.additional_kwargs["subagent_batch_id"] == "subagent-batch-1"
|
|
assert "running independently" in message.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_task_rejects_duplicate_item_keys_without_submitting(monkeypatch) -> None:
|
|
submitter = AsyncMock()
|
|
monkeypatch.setattr(tool_module, "get_subagent_batch_submitter", lambda: submitter)
|
|
|
|
command = await tool_module.batch_task.coroutine(
|
|
runtime=_runtime(),
|
|
title="Duplicates",
|
|
items=[
|
|
BatchTaskItem(key="same", prompt="one"),
|
|
BatchTaskItem(key="same", prompt="two"),
|
|
],
|
|
subagent_type="general-purpose",
|
|
tool_call_id="call-1",
|
|
)
|
|
|
|
message = _message(command)
|
|
assert message.status == "error"
|
|
assert "unique" in message.content
|
|
submitter.submit.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bound_batch_tools_use_the_explicit_submitter(monkeypatch) -> None:
|
|
explicit = AsyncMock()
|
|
explicit.get_batch.return_value = {
|
|
"id": "subagent-batch-explicit",
|
|
"status": "running",
|
|
"total_items": 2,
|
|
"counts": {"running": 1, "succeeded": 1},
|
|
}
|
|
fallback = AsyncMock()
|
|
monkeypatch.setattr(tool_module, "get_subagent_batch_submitter", lambda: fallback)
|
|
|
|
tools = {tool.name: tool for tool in tool_module.bind_batch_tools(explicit)}
|
|
result = await tools["batch_status"].coroutine(
|
|
runtime=_runtime(),
|
|
batch_id="subagent-batch-explicit",
|
|
)
|
|
|
|
assert "subagent-batch-explicit" in result
|
|
explicit.get_batch.assert_awaited_once_with(
|
|
batch_id="subagent-batch-explicit",
|
|
user_id="user-1",
|
|
)
|
|
fallback.get_batch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bound_batch_task_uses_the_explicit_app_config(monkeypatch) -> None:
|
|
app_config = object()
|
|
captured = {}
|
|
submitter = AsyncMock()
|
|
submitter.submit.return_value = {
|
|
"id": "subagent-batch-explicit",
|
|
"status": "queued",
|
|
"total_items": 1,
|
|
}
|
|
|
|
def available_names(*, app_config, allowed_subagents):
|
|
captured["names"] = (app_config, allowed_subagents)
|
|
return ["general-purpose"]
|
|
|
|
def subagent_config(name, *, app_config):
|
|
captured["config"] = (name, app_config)
|
|
return SubagentConfig(
|
|
name="general-purpose",
|
|
description="General purpose",
|
|
)
|
|
|
|
monkeypatch.setattr(tool_module, "get_available_subagent_names", available_names)
|
|
monkeypatch.setattr(tool_module, "get_subagent_config", subagent_config)
|
|
|
|
tools = {
|
|
tool.name: tool
|
|
for tool in tool_module.bind_batch_tools(
|
|
submitter,
|
|
app_config=app_config,
|
|
)
|
|
}
|
|
await tools["batch_task"].coroutine(
|
|
runtime=_runtime(),
|
|
title="Explicit config",
|
|
items=[BatchTaskItem(key="record-1", prompt="Process one")],
|
|
subagent_type="general-purpose",
|
|
tool_call_id="call-explicit",
|
|
max_live_items=None,
|
|
max_running_items=None,
|
|
)
|
|
|
|
assert captured["names"] == (app_config, ["general-purpose"])
|
|
assert captured["config"] == ("general-purpose", app_config)
|
|
submitter.submit.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bound_batch_tools_do_not_fall_back_after_runtime_stops(monkeypatch) -> None:
|
|
fallback = AsyncMock()
|
|
monkeypatch.setattr(tool_module, "get_subagent_batch_submitter", lambda: fallback)
|
|
|
|
tools = {
|
|
tool.name: tool
|
|
for tool in tool_module.bind_batch_tools(
|
|
submitter_provider=lambda: None,
|
|
)
|
|
}
|
|
result = await tools["batch_status"].coroutine(
|
|
runtime=_runtime(),
|
|
batch_id="subagent-batch-stopped",
|
|
)
|
|
|
|
assert result == "Durable subagent batches are unavailable."
|
|
fallback.get_batch.assert_not_awaited()
|