mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 16:37:55 +00:00
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.
This commit is contained in:
parent
8ade23d779
commit
ca18cf0b24
@ -162,7 +162,11 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
config = self._get_title_config()
|
config = self._get_title_config()
|
||||||
fallback_chars = min(config.max_chars, 50)
|
fallback_chars = min(config.max_chars, 50)
|
||||||
if len(user_msg) > fallback_chars:
|
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"
|
return user_msg if user_msg else "New Conversation"
|
||||||
|
|
||||||
def _get_runnable_config(self) -> dict[str, Any]:
|
def _get_runnable_config(self) -> dict[str, Any]:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import asyncio
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
from langgraph.constants import TAG_NOSTREAM
|
from langgraph.constants import TAG_NOSTREAM
|
||||||
|
|
||||||
@ -319,6 +320,51 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
result = middleware._generate_title_result(state)
|
result = middleware._generate_title_result(state)
|
||||||
assert result["title"].endswith("...")
|
assert result["title"].endswith("...")
|
||||||
assert result["title"].startswith("这是一个非常长的问题描述")
|
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):
|
def test_parse_title_strips_think_tags(self):
|
||||||
"""Title model responses with <think>...</think> blocks are stripped before use."""
|
"""Title model responses with <think>...</think> blocks are stripped before use."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user