From 6f24bc7c363775d00490734ff3100bc9d90ce192 Mon Sep 17 00:00:00 2001 From: yetuge <182123929+yetuge@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:31:57 +0800 Subject: [PATCH] fix(title): build the title model off the Gateway event loop (#7813) Thread title generation runs inside the agent graph (aafter_model), and it built its configured model inline. create_chat_model can read local CLI credentials while it is constructed (Codex auth.json, Claude Code credential files), so the first turn of every new thread read them on the shared loop and other requests waited for it. Build the provider through run_assembly, the dedicated assembly pool the one-shot helpers, skill moderation, the goal evaluator and start_run already use. Inference still awaits on the loop, and the sync path is unchanged. A Blockbuster regression builds the real Codex provider: on main the gate rejects the credential read, the broad fallback swallows it, and the local fallback title replaces the model's title; with the change the provider is constructed on a worker thread and the model's title is kept. --- README.md | 6 +- .../agents/middlewares/title_middleware.py | 7 +- .../packages/harness/deerflow/utils/AGENTS.md | 27 ++++--- .../blocking_io/test_title_model_offloop.py | 81 +++++++++++++++++++ 4 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 backend/tests/blocking_io/test_title_model_offloop.py diff --git a/README.md b/README.md index 78e9b64c7e..05400d9b82 100644 --- a/README.md +++ b/README.md @@ -2573,9 +2573,9 @@ Backend response cleanup preserves unrelated tag names such as `` an The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message. Input polishing, follow-up suggestions, background project-file summaries, the -LLM skill scanner, and the `/goal` evaluator construct their model providers off -the Gateway event loop, so local credential reads do not stall unrelated -requests. +LLM skill scanner, thread title generation, and the `/goal` evaluator construct +their model providers off the Gateway event loop, so local credential reads do +not stall unrelated requests. When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input. Accompanying answer text stays outside the execution steps panel, and answering the request keeps previously completed text in the conversation while the agent continues. diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index f10cfa54f5..c557fb47d3 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -17,6 +17,7 @@ from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_co from deerflow.agents.middlewares.pii_redaction_middleware import redact_texts from deerflow.config.title_config import get_title_config from deerflow.models import create_chat_model +from deerflow.utils.assembly_io import run_assembly from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text if TYPE_CHECKING: @@ -346,7 +347,11 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): model_kwargs = {"thinking_enabled": False, "attach_tracing": False} if self._app_config is not None: model_kwargs["app_config"] = self._app_config - model = create_chat_model(name=config.model_name, **model_kwargs) + # Provider construction can read local CLI credentials (Codex + # auth.json, Claude Code credential files). This hook runs on the + # Gateway event loop, so build the provider on the assembly pool + # (utils/AGENTS.md) instead of stalling every other request. + model = await run_assembly(create_chat_model, name=config.model_name, **model_kwargs) invoke_config = self._get_runnable_config() from deerflow_extension_api import SystemOperationKind diff --git a/backend/packages/harness/deerflow/utils/AGENTS.md b/backend/packages/harness/deerflow/utils/AGENTS.md index 235cbeeedf..481290bb83 100644 --- a/backend/packages/harness/deerflow/utils/AGENTS.md +++ b/backend/packages/harness/deerflow/utils/AGENTS.md @@ -121,19 +121,20 @@ Display summaries use `strip_leading_think_blocks` before limiting text; ### Agent / Tool Assembly Off-Load One-shot input-polish, follow-up-suggestion, and project-file-summary helpers, -skill moderation (`scan_skill_content`) and the goal evaluator build providers -through `run_assembly` before awaiting inference; the run worker calls its -cached evaluator factory the same way. Provider construction can read local -credentials; keep it off the event loop and the default executor while -preserving request context. Cancellation during construction must not start -inference. A scan without `app_config` (in-graph `skill_manage`) reads the live -config in the same pool, and its fail-closed fallback reuses that config rather -than reading it again on the loop. Other model construction is separate and -not covered here: manual compaction (`compact_thread_context` builds the -summarization middleware and its models inline) and, inside graph runs, title -generation, summarization and subagent construction. -Coverage: `tests/blocking_io/test_oneshot_llm_offloop.py` and -`tests/blocking_io/test_system_model_offloop.py`. +skill moderation (`scan_skill_content`), the goal evaluator, and thread title +generation build providers through `run_assembly` before awaiting inference; +the run worker calls its cached evaluator factory the same way. Provider +construction can read local credentials; keep it off the event loop and the +default executor while preserving request context. Cancellation during +construction must not start inference. A scan without `app_config` (in-graph +`skill_manage`) reads the live config in the same pool, and its fail-closed +fallback reuses that config rather than reading it again on the loop. Other +model construction is separate and not covered here: manual compaction +(`compact_thread_context` builds the summarization middleware and its models +inline) and, inside graph runs, summarization and subagent construction. +Coverage: `tests/blocking_io/test_oneshot_llm_offloop.py`, +`tests/blocking_io/test_system_model_offloop.py` and +`tests/blocking_io/test_title_model_offloop.py`. `start_run` also resolves the factory through `run_assembly` before admission: cold imports cannot occupy the loop or its default pool, and import errors diff --git a/backend/tests/blocking_io/test_title_model_offloop.py b/backend/tests/blocking_io/test_title_model_offloop.py new file mode 100644 index 0000000000..25227b0a30 --- /dev/null +++ b/backend/tests/blocking_io/test_title_model_offloop.py @@ -0,0 +1,81 @@ +"""Title generation must build its provider off the Gateway event loop. + +Inside a graph run, ``aafter_model`` builds the configured title model inline. +``create_chat_model`` can read local CLI credentials (Codex ``auth.json``, +Claude Code credential files) while it is constructed, so every other request +on the shared loop waited for that read. The real Codex provider is built under +the strict gate; tracing and inference stay offline. +""" + +import json +import threading +from types import SimpleNamespace + +import pytest +from langchain_core.messages import AIMessage, HumanMessage + +from deerflow.agents.middlewares import title_middleware +from deerflow.agents.middlewares.title_middleware import TitleMiddleware +from deerflow.config.app_config import AppConfig +from deerflow.models import factory +from deerflow.models.openai_codex_provider import CodexChatModel + +pytestmark = pytest.mark.asyncio + +_TITLE = "Deployment plan" + + +def _codex_config() -> AppConfig: + return AppConfig.model_validate( + { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": [{"name": "fixture-codex", "model": "fixture-model", "use": "deerflow.models.openai_codex_provider:CodexChatModel"}], + "title": {"model_name": "fixture-codex"}, + } + ) + + +@pytest.fixture +def codex(tmp_path, monkeypatch): + """Real Codex construction (credential file IO included); offline tracing and inference.""" + credentials = tmp_path / "auth.json" + credentials.write_text(json.dumps({"access_token": "fixture-token", "account_id": "fixture-account"}), encoding="utf-8") + monkeypatch.setenv("CODEX_AUTH_PATH", str(credentials)) + monkeypatch.setattr(factory, "build_tracing_callbacks", lambda **kwargs: []) + state = SimpleNamespace(construction_thread=None, calls=[]) + + async def invoke(model, messages, *, config): + state.calls.append((model, threading.get_ident())) + return AIMessage(content=_TITLE) + + monkeypatch.setattr(CodexChatModel, "ainvoke", invoke) + return state + + +async def test_title_model_is_built_off_the_event_loop(codex, monkeypatch): + middleware = TitleMiddleware(app_config=_codex_config()) + real_create = title_middleware.create_chat_model + + def recording_create(*args, **kwargs): + codex.construction_thread = threading.get_ident() + return real_create(*args, **kwargs) + + monkeypatch.setattr(title_middleware, "create_chat_model", recording_create) + state = { + "messages": [ + HumanMessage(content="Plan the eu-west-2 deployment.", id="user"), + AIMessage(content="The plan is ready.", id="assistant"), + ] + } + + result = await middleware._agenerate_title_result(state) + + # On the loop the gate rejects the credential read, the broad fallback in + # _agenerate_title_result swallows it, and the local title replaces the + # configured model's title. + assert result == {"title": _TITLE} + assert len(codex.calls) == 1 + assert codex.construction_thread is not None + assert codex.construction_thread != threading.get_ident() + # Inference itself still awaits on the loop once the provider exists. + assert codex.calls[0][1] == threading.get_ident()