Files

459 lines
17 KiB
Python

"""Slow agent initialization must not monopolize the gateway event loop."""
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from contextvars import ContextVar
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
from deerflow.tools.builtins.tool_search import DeferredToolRegistry, get_deferred_registry, reset_deferred_registry, set_deferred_registry
from deerflow.utils import background_init
from deerflow.utils.file_io import FileIOPool
def _background(factory):
factory._deerflow_background_build = True
return factory
@pytest.fixture
def build_pool(monkeypatch):
pool = FileIOPool(1, 1, "test-agent-init")
monkeypatch.setattr(background_init, "_INITIALIZATION_IO", pool)
yield pool
pool._pool.shutdown(wait=True)
@pytest.mark.asyncio
async def test_shared_initializer_forwards_arguments_and_commits_context(build_pool):
from deerflow.mcp.cache import _owner_initialization_loop
loop = asyncio.get_running_loop()
caller_thread = threading.get_ident()
state = ContextVar("shared-initializer-context", default="before")
def initialize(value, *, suffix):
assert threading.get_ident() != caller_thread
assert _owner_initialization_loop.get() is loop
assert state.get() == "before"
state.set(None)
return value + suffix
assert await background_init.run_background_init(initialize, "tools", suffix=" ready") == "tools ready"
assert state.get() is None
assert _owner_initialization_loop.get() is None
@pytest.mark.asyncio
async def test_failed_shared_initializer_does_not_commit_context(build_pool):
state = ContextVar("failed-shared-initializer-context", default="before")
def fail():
state.set("partially initialized")
raise ValueError("initialization failed")
with pytest.raises(ValueError, match="initialization failed"):
await background_init.run_background_init(fail)
assert state.get() == "before"
assert await background_init.run_background_init(lambda: state.get()) == "before"
class FinishedAgent:
async def astream(self, *args, **kwargs):
yield {"messages": []}
async def _make_run(monkeypatch, factory, *, config=None, app_config=None, thread_id="build-responsiveness"):
manager = RunManager()
record = await manager.create(thread_id)
bridge = MemoryStreamBridge()
monkeypatch.setattr(bridge, "cleanup", AsyncMock())
task = asyncio.create_task(
worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(checkpointer=None, app_config=app_config),
agent_factory=factory,
graph_input={},
config=config or {},
)
)
record.task = task
return SimpleNamespace(task=task, manager=manager, record=record, bridge=bridge)
@pytest.mark.asyncio
async def test_slow_agent_build_keeps_parallel_requests_responsive(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
entered = asyncio.Event()
factory_finished = threading.Event()
factory_started_at = []
@_background
def factory(*, config):
factory_started_at.append(time.perf_counter())
loop.call_soon_threadsafe(entered.set)
deadline = time.perf_counter() + 0.18
while time.perf_counter() < deadline:
pass
factory_finished.set()
return FinishedAgent()
run = await _make_run(monkeypatch, factory)
await asyncio.wait_for(entered.wait(), 3)
probe_delay = time.perf_counter() - factory_started_at[0]
responsive = not factory_finished.is_set()
await asyncio.wait_for(run.task, 3)
assert run.record.status == RunStatus.success
assert run.bridge._streams[run.record.run_id].ended
print(f"agent build concurrent probe delay = {probe_delay:.3f}s")
assert responsive, f"Agent initialization blocked parallel requests for {probe_delay:.3f}s"
@pytest.mark.asyncio
async def test_custom_factory_keeps_caller_loop_and_context_mutations(monkeypatch):
"""Third-party synchronous factories have no worker-safety declaration."""
loop = asyncio.get_running_loop()
caller_thread = threading.get_ident()
context = ContextVar("custom-build-context", default="before")
observed = []
app_config = object()
class Agent:
async def astream(self, *args, **kwargs):
observed.append(context.get())
assert asyncio.get_running_loop() is loop
yield {"messages": []}
def factory(*, config, app_config):
assert asyncio.get_running_loop() is loop
assert threading.get_ident() == caller_thread
assert app_config is expected_app_config
assert config["context"]["app_config"] is app_config
context.set("set by custom factory")
return Agent()
expected_app_config = app_config
run = await _make_run(monkeypatch, factory, app_config=app_config)
await asyncio.wait_for(run.task, 3)
assert run.record.status == RunStatus.success
assert observed == ["set by custom factory"]
assert context.get() == "before", "Run-local state must not leak to its caller"
@pytest.mark.asyncio
async def test_factory_failure_ends_stream_and_does_not_poison_next_run(monkeypatch, build_pool):
@_background
def failing_factory(*, config):
raise ValueError("synthetic initialization failure")
failed = await _make_run(monkeypatch, failing_factory)
await asyncio.wait_for(failed.task, 3)
assert failed.record.status == RunStatus.error
assert failed.record.error == "synthetic initialization failure"
stream = failed.bridge._streams[failed.record.run_id]
assert stream.ended
assert any(event.event == "error" for event in stream.events)
recovered = await _make_run(monkeypatch, _background(lambda **kwargs: FinishedAgent()))
await asyncio.wait_for(recovered.task, 3)
assert recovered.record.status == RunStatus.success
assert recovered.bridge._streams[recovered.record.run_id].ended
@pytest.mark.asyncio
@pytest.mark.parametrize("clear_registry", [False, True])
async def test_background_build_preserves_user_config_and_deferred_registry(monkeypatch, build_pool, clear_registry):
loop = asyncio.get_running_loop()
loop_thread = threading.get_ident()
request_user = ContextVar("build-request-user", default="outside")
inherited_registry = DeferredToolRegistry()
previous_registry = get_deferred_registry()
set_deferred_registry(inherited_registry)
observations = {}
app_configs = {name: object() for name in ("alice", "bob")}
registries = {name: DeferredToolRegistry() for name in app_configs}
class Agent:
def __init__(self, user):
self.user = user
async def astream(self, *args, config, **kwargs):
assert asyncio.get_running_loop() is loop
assert threading.get_ident() == loop_thread
observations[self.user] = (request_user.get(), get_deferred_registry(), config["configurable"]["built_for"])
yield {"messages": []}
@_background
def factory(*, config, app_config):
user = request_user.get()
assert threading.get_ident() != loop_thread
assert app_config is app_configs[user]
assert config["context"]["app_config"] is app_config
assert get_deferred_registry() is inherited_registry
config["configurable"]["built_for"] = user
if clear_registry:
reset_deferred_registry()
else:
set_deferred_registry(registries[user])
return Agent(user)
runs = []
try:
for user in app_configs:
token = request_user.set(user)
try:
runs.append(await _make_run(monkeypatch, factory, app_config=app_configs[user], thread_id=user))
finally:
request_user.reset(token)
await asyncio.wait_for(asyncio.gather(*(run.task for run in runs)), 3)
assert all(run.record.status == RunStatus.success for run in runs)
assert request_user.get() == "outside"
assert get_deferred_registry() is inherited_registry
for user in app_configs:
assert observations[user] == (user, None if clear_registry else registries[user], user)
finally:
set_deferred_registry(previous_registry)
@pytest.mark.asyncio
async def test_build_queue_serializes_initialization_without_consuming_default_executor(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
first_entered = asyncio.Event()
release = threading.Event()
counter_lock = threading.Lock()
active = peak = entered = 0
default_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test-general-worker")
loop.set_default_executor(default_pool)
@_background
def factory(*, config):
nonlocal active, peak, entered
with counter_lock:
active += 1
entered += 1
peak = max(active, peak)
loop.call_soon_threadsafe(first_entered.set)
try:
assert release.wait(5), "Test did not release initialization worker"
return FinishedAgent()
finally:
with counter_lock:
active -= 1
runs = [await _make_run(monkeypatch, factory, thread_id=f"queue-{index}") for index in range(4)]
try:
await asyncio.wait_for(first_entered.wait(), 3)
assert await asyncio.wait_for(asyncio.to_thread(lambda: "authentication worker available"), 1) == "authentication worker available"
await asyncio.sleep(0.03)
assert entered == 1, "Cold builders must wait without competing for shared initialization state"
finally:
release.set()
await asyncio.wait_for(asyncio.gather(*(run.task for run in runs)), 3)
default_pool.shutdown(wait=True)
assert peak == 1
assert entered == 4
assert all(run.record.status == RunStatus.success for run in runs)
@pytest.mark.asyncio
async def test_repeated_cancel_during_build_drains_worker_without_starting_graph(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
graph_started = []
finalization_registries = []
original_finalize = worker._finalize_run
previous_registry = get_deferred_registry()
caller_registry = DeferredToolRegistry()
set_deferred_registry(caller_registry)
async def finalize(**kwargs):
finalization_registries.append(get_deferred_registry())
await original_finalize(**kwargs)
monkeypatch.setattr(worker, "_finalize_run", finalize)
class Agent:
async def astream(self, *args, **kwargs):
graph_started.append(True)
yield {"messages": []}
@_background
def factory(*, config):
set_deferred_registry(DeferredToolRegistry())
loop.call_soon_threadsafe(entered.set)
assert release.wait(5), "Test did not release cancelled builder"
return Agent()
run = await _make_run(monkeypatch, factory)
try:
await asyncio.wait_for(entered.wait(), 3)
assert await run.manager.cancel(run.record.run_id)
await asyncio.sleep(0)
assert not run.task.done(), "Cancellation must not abandon a live initialization worker"
run.task.cancel()
await asyncio.sleep(0)
assert not run.task.done(), "Repeated cancellation must still drain the builder"
finally:
release.set()
await asyncio.wait_for(run.task, 3)
set_deferred_registry(previous_registry)
assert not graph_started
assert run.record.status == RunStatus.interrupted
assert run.bridge._streams[run.record.run_id].ended
assert finalization_registries == [caller_registry], "Cancelled build context must not be committed to the run"
@pytest.mark.asyncio
async def test_queued_cancel_does_not_build_or_wait_for_active_factory(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
queued_built = []
@_background
def first_factory(*, config):
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
return FinishedAgent()
@_background
def queued_factory(*, config):
queued_built.append(True)
return FinishedAgent()
first = await _make_run(monkeypatch, first_factory, thread_id="active")
queued = None
try:
await asyncio.wait_for(entered.wait(), 3)
queued = await _make_run(monkeypatch, queued_factory, thread_id="queued")
await asyncio.sleep(0.02)
await queued.manager.cancel(queued.record.run_id)
await asyncio.wait_for(queued.task, 1)
assert queued.record.status == RunStatus.interrupted
assert queued.bridge._streams[queued.record.run_id].ended
assert not queued_built
assert not first.task.done()
finally:
release.set()
await asyncio.wait_for(asyncio.gather(first.task, *([queued.task] if queued else [])), 3)
assert first.record.status == RunStatus.success
@pytest.mark.asyncio
async def test_abort_flag_set_during_build_prevents_late_stream_start(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
graph_started = []
class Agent:
async def astream(self, *args, **kwargs):
graph_started.append(True)
yield {"messages": []}
@_background
def factory(*, config):
loop.call_soon_threadsafe(entered.set)
assert release.wait(5)
return Agent()
run = await _make_run(monkeypatch, factory)
try:
await asyncio.wait_for(entered.wait(), 3)
run.record.abort_event.set()
finally:
release.set()
await asyncio.wait_for(run.task, 3)
assert graph_started == []
assert run.record.status == RunStatus.interrupted
assert run.bridge._streams[run.record.run_id].ended
@pytest.mark.asyncio
async def test_existing_conversation_keeps_streaming_while_another_agent_initializes(monkeypatch, build_pool):
loop = asyncio.get_running_loop()
stream_started = asyncio.Event()
continue_stream = asyncio.Event()
build_entered = asyncio.Event()
release_build = threading.Event()
build_finished = threading.Event()
class StreamingAgent:
async def astream(self, *args, **kwargs):
stream_started.set()
yield {"messages": [], "answer": "first delta"}
await continue_stream.wait()
yield {"messages": [], "answer": "second delta"}
@_background
def slow_factory(*, config):
loop.call_soon_threadsafe(build_entered.set)
assert release_build.wait(5), "Test did not release the second conversation's initializer"
build_finished.set()
return FinishedAgent()
existing = await _make_run(monkeypatch, lambda **kwargs: StreamingAgent(), thread_id="already-streaming")
initializing = None
try:
await asyncio.wait_for(stream_started.wait(), 3)
initializing = await _make_run(monkeypatch, slow_factory, thread_id="cold-conversation")
await asyncio.wait_for(build_entered.wait(), 3)
continue_stream.set()
await asyncio.wait_for(existing.task, 1)
stream = existing.bridge._streams[existing.record.run_id]
answers = [event.data["answer"] for event in stream.events if event.event == "values"]
assert answers == ["first delta", "second delta"]
assert stream.ended
assert existing.record.status == RunStatus.success
assert not initializing.task.done()
assert not build_finished.is_set(), "The existing answer must finish before the cold build is released"
finally:
continue_stream.set()
release_build.set()
await asyncio.wait_for(asyncio.gather(existing.task, *([initializing.task] if initializing else [])), 3)
assert initializing.record.status == RunStatus.success
@pytest.mark.asyncio
async def test_builtin_lead_factory_uses_background_initializer(monkeypatch, build_pool):
from deerflow.agents.lead_agent import agent as lead_agent
loop_thread = threading.get_ident()
app_config = object()
observed_build_threads = []
def build_body(config, *, app_config):
assert app_config is expected_config
observed_build_threads.append(threading.get_ident())
return FinishedAgent()
expected_config = app_config
monkeypatch.setattr(lead_agent, "_make_lead_agent", build_body)
# Exercise the real exported factory: a missing production opt-in must fail
# even if all tests using synthetic marked factories continue to pass.
run = await _make_run(monkeypatch, lead_agent.make_lead_agent, app_config=app_config)
await asyncio.wait_for(run.task, 3)
assert run.record.status == RunStatus.success
assert len(observed_build_threads) == 1
assert observed_build_threads[0] != loop_thread