diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index d2c9e1e3b8..28c86b1e38 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -1025,7 +1025,11 @@ async def task_tool( parent_tool_groups = metadata.get("tool_groups") resolved_app_config = runtime_app_config if config.model == "inherit" and parent_model is None and resolved_app_config is None: - resolved_app_config = get_app_config() + # get_app_config() reads config/extension files (and may fully reload + # them) on every miss. Resolve it on the assembly pool like every other + # fallback above so the inherit path cannot stall the calling event + # loop (issue #5172). + resolved_app_config = await run_assembly(get_app_config) effective_model = resolve_subagent_model_name(config, parent_model, app_config=resolved_app_config) # Subagents should not have subagent tools enabled (prevent recursive diff --git a/backend/tests/blocking_io/test_tool_assembly_offloop.py b/backend/tests/blocking_io/test_tool_assembly_offloop.py index c265833603..c6a089eb57 100644 --- a/backend/tests/blocking_io/test_tool_assembly_offloop.py +++ b/backend/tests/blocking_io/test_tool_assembly_offloop.py @@ -160,6 +160,78 @@ async def test_task_tool_assembles_off_loop(monkeypatch, tmp_path): assert all(thread is not threading.main_thread() for thread in observed_threads) +async def test_task_tool_inherit_app_config_resolves_off_loop(monkeypatch, tmp_path): + """task_tool's inherit-path ``get_app_config`` fallback dispatches to a worker thread. + + ``get_app_config()`` stats and hashes the config file and revalidates the + extensions config on **every** call, so the fallback the inherit branch + takes when neither the runtime nor the caller threads an app config must + not run it on the loop. The existing ``test_task_tool_assembles_off_loop`` + cannot cover this: its runtime sets ``metadata.model_name``, so the + ``selected_model`` branch replaces ``config`` and the inherit fallback + never fires — which is why the gate missed this stall. + """ + (tmp_path / "probe.txt").write_text("probe body", encoding="utf-8") + observed_threads: list = [] + fallback_app_config = SimpleNamespace(models=[SimpleNamespace(name="default-model")]) + + def _blocking_probe_app_config(): + # Real filesystem IO: trips the strict gate when it runs on the loop. + probe_file.read_text(encoding="utf-8") + observed_threads.append(threading.current_thread()) + return fallback_app_config + + probe_file = tmp_path / "probe.txt" + monkeypatch.setattr(task_tool_module, "get_app_config", _blocking_probe_app_config) + monkeypatch.setattr(task_tool_module, "SubagentStatus", _FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", _DummyExecutor) + monkeypatch.setattr( + task_tool_module, + "get_subagent_config", + lambda _name: SubagentConfig( + name="general-purpose", + description="General helper", + system_prompt="Base system prompt", + max_turns=50, + timeout_seconds=10, + ), + ) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda **_kwargs: ["general-purpose"]) + observed_tools_kwargs: list[dict] = [] + + def _record_tools(**kwargs): + observed_tools_kwargs.append(kwargs) + return [] + + monkeypatch.setattr("deerflow.tools.get_available_tools", _record_tools) + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _task_id: _completed_result()) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None) + + async def _no_sleep(_: float) -> None: + return None + + monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) + + tool = task_tool_module.task_tool + invoke = getattr(tool, "coroutine", None) or getattr(tool, "func", None) + assert invoke is not None + command = await invoke( + runtime=None, + description="test", + prompt="p", + subagent_type="general-purpose", + tool_call_id="tc-inherit-offloop", + ) + + messages = command.update["messages"] + assert len(messages) == 1 + assert isinstance(messages[0], ToolMessage) + assert observed_threads, "the inherit app-config fallback must be invoked" + assert all(thread is not threading.main_thread() for thread in observed_threads) + assert observed_tools_kwargs, "tool assembly must be invoked" + assert all(kwargs.get("app_config") is fallback_app_config for kwargs in observed_tools_kwargs) + + async def test_batch_item_assembles_off_loop(monkeypatch, tmp_path): """SubagentBatchService._execute_item dispatches assembly to a worker thread.""" (tmp_path / "probe.txt").write_text("probe body", encoding="utf-8") diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 6b587eb5cf..fd91c18088 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -1464,6 +1464,53 @@ def test_task_tool_runtime_none_passes_groups_none(monkeypatch): assert events[-1]["error"] == "subagent crashed" +def test_task_tool_inherit_model_resolves_app_config_off_loop(monkeypatch): + """The inherit-path app-config fallback must not run blocking config IO on the calling event loop (issue #5172).""" + config = _make_subagent_config() + events = [] + loop_thread_id = threading.get_ident() + caller_threads: list[int] = [] + get_available_tools = MagicMock(return_value=[]) + + def record_get_app_config(): + caller_threads.append(threading.get_ident()) + return SimpleNamespace(models=[SimpleNamespace(name="default-model")]) + + class DummyExecutor: + def __init__(self, **kwargs): + pass + + def execute_async(self, prompt, task_id=None): + return task_id or "generated-task-id" + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: config) + monkeypatch.setattr( + task_tool_module, + "get_background_task_result", + lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="ok"), + ) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: events.append) + monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) + monkeypatch.setattr("deerflow.tools.get_available_tools", get_available_tools) + monkeypatch.setattr(task_tool_module, "get_app_config", record_get_app_config) + + output = _run_task_tool( + runtime=None, + description="执行任务", + prompt="inherit fallback", + subagent_type="general-purpose", + tool_call_id="tc-inherit-off-loop", + ) + + assert _task_tool_message(output).content == "Task Succeeded. Result: ok" + assert caller_threads, "expected the inherit fallback to resolve the app config" + assert all(thread_id != loop_thread_id for thread_id in caller_threads), "get_app_config ran on the event loop thread" + # The resolved fallback config must flow through to tool assembly. + assert get_available_tools.call_args.kwargs.get("app_config").models[0].name == "default-model" + + def test_task_tool_returns_timed_out_message(monkeypatch): config = _make_subagent_config() events = []