fix(subagents): resolve the inherit-path app config off the event loop (#8134)

* fix(subagents): resolve the inherit-path app config off the event loop

task_tool's inherit-model fallback called get_app_config() synchronously
inside the async tool body. get_app_config() reads config and extension
files on every cache miss (and may fully reload them), so the fallback
path stalls the calling streaming event loop for the duration of the
disk IO - the same class of loop stall removed for tool assembly in
issue #5172. Every other config resolution in this tool already
dispatches through run_assembly; route this one through it too.

* chore: drop the CHANGELOG entry per maintainer request

Changelogs for merged PRs are created in batch upstream; keeping the
entry here only risks merge conflicts.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-11-12 16:59:02 +08:00 committed by GitHub
parent 7ae36b469d
commit 416a0647a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 124 additions and 1 deletions

View File

@ -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

View File

@ -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")

View File

@ -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 = []