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.
This commit is contained in:
yetuge 2026-11-04 04:31:57 +08:00 committed by GitHub
parent 1dd6d05627
commit 6f24bc7c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 104 additions and 17 deletions

View File

@ -2573,9 +2573,9 @@ Backend response cleanup preserves unrelated tag names such as `<think-tank>` 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.

View File

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

View File

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

View File

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