From ca18cf0b24352dbf77cd3df2f308e7675e7868f3 Mon Sep 17 00:00:00 2001 From: Aari Date: Sat, 11 Jul 2026 23:15:03 +0800 Subject: [PATCH] fix(agent): reserve ellipsis room so the local title respects max_chars (#4052) `_fallback_title` sliced the user message to `min(max_chars, 50)` and then appended a three-character ellipsis, so the returned title could be three characters longer than the configured cap. `_parse_title`, six lines above, slices the model's answer to `max_chars` exactly -- both read the same `TitleConfig.max_chars`, only one honoured it. This is the default path, not an error branch: `config.example.yaml` ships `title.model_name: null` ("null = fast local fallback"), so every title is produced here unless the operator opts into a title model. `max_chars` is a documented key with a pydantic range of 10..200; any value in 10..52 makes a long first message overshoot its cap. Reserve room for the ellipsis before slicing. At the shipped `max_chars: 60` the body is still 50 characters, so default output is unchanged. The existing `test_sync_generate_title_respects_fallback_truncation` asserted the shape of the truncation but never its length -- at its own `max_chars=50` it was passing on a 53-character title. It now asserts the bound it is named after. --- .../agents/middlewares/title_middleware.py | 6 ++- .../tests/test_title_middleware_core_logic.py | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index 0b75a71f0..b2ac92c27 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -162,7 +162,11 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): config = self._get_title_config() fallback_chars = min(config.max_chars, 50) if len(user_msg) > fallback_chars: - return user_msg[:fallback_chars].rstrip() + "..." + # Reserve room for the ellipsis so this path honours ``max_chars`` + # exactly as ``_parse_title`` does on the model path. + ellipsis = "..." + body = min(fallback_chars, config.max_chars - len(ellipsis)) + return user_msg[:body].rstrip() + ellipsis return user_msg if user_msg else "New Conversation" def _get_runnable_config(self) -> dict[str, Any]: diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index 8f99f2054..14b03a475 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -4,6 +4,7 @@ import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import pytest from langchain_core.messages import AIMessage, HumanMessage from langgraph.constants import TAG_NOSTREAM @@ -319,6 +320,51 @@ class TestTitleMiddlewareCoreLogic: result = middleware._generate_title_result(state) assert result["title"].endswith("...") assert result["title"].startswith("这是一个非常长的问题描述") + assert len(result["title"]) <= 50 + + @pytest.mark.parametrize("max_chars", [10, 20, 40, 49, 50, 52, 53, 60, 200]) + def test_fallback_title_never_exceeds_max_chars(self, max_chars): + """``max_chars`` bounds the fallback title, not just its body. + + The ellipsis is part of the returned title, so a body of exactly + ``min(max_chars, 50)`` characters overshot the configured cap by three. + ``model_name: null`` is the shipped default, so this is the path every + title takes out of the box -- not an error branch. + """ + _set_test_title_config(max_chars=max_chars, model_name=None) + middleware = TitleMiddleware() + + title = middleware._fallback_title("x" * 200) + + assert len(title) <= max_chars + assert title.endswith("...") + + @pytest.mark.parametrize("max_chars", [10, 20, 50]) + def test_fallback_title_honours_the_same_cap_as_the_model_path(self, max_chars): + """Both title paths read ``config.max_chars``; both must respect it. + + ``_parse_title`` slices the model's answer to ``max_chars`` exactly. The + local path is the other half of the same contract. + """ + _set_test_title_config(max_chars=max_chars, model_name=None) + middleware = TitleMiddleware() + long_text = "x" * 200 + + assert len(middleware._parse_title(long_text)) <= max_chars + assert len(middleware._fallback_title(long_text)) <= max_chars + + def test_fallback_title_keeps_default_config_output_unchanged(self): + """The default ``max_chars=60`` leaves room for the ellipsis already. + + Reserving that room must not shorten titles that were never over the + cap, so the shipped configuration keeps emitting a 50-character body. + """ + _set_test_title_config(max_chars=60, model_name=None) + middleware = TitleMiddleware() + + title = middleware._fallback_title("x" * 200) + + assert title == "x" * 50 + "..." def test_parse_title_strips_think_tags(self): """Title model responses with ... blocks are stripped before use."""