fix(title): auto-title extraction for tool-heavy first turns — closes #639 (PR #640 by @franksong2702)
The auto-title extractor now uses _looks_invalid_generated_title() to distinguish tool-call preambles from substantive agentic replies. Fixes _is_provisional_title() whitespace normalization. 5 regression tests added. Independent review by @nesquena (a553b2b+a0ca9fe).
This commit is contained in:
@@ -32,6 +32,12 @@
|
|||||||
- **Sidebar nav icon hit targets are now correctly aligned** — added `display:flex; align-items:center; justify-content:center` to `.nav-tab` so clicking the icon itself (not below it) activates the tab. (Closes #636)
|
- **Sidebar nav icon hit targets are now correctly aligned** — added `display:flex; align-items:center; justify-content:center` to `.nav-tab` so clicking the icon itself (not below it) activates the tab. (Closes #636)
|
||||||
- **Safari iOS input auto-zoom fixed** — bumped `textarea#msg` base font-size from 14px to 16px, which prevents Safari from zooming the viewport on input focus (Safari zooms when font-size < 16px). Visual difference is negligible. (Closes #630)
|
- **Safari iOS input auto-zoom fixed** — bumped `textarea#msg` base font-size from 14px to 16px, which prevents Safari from zooming the viewport on input focus (Safari zooms when font-size < 16px). Visual difference is negligible. (Closes #630)
|
||||||
|
|
||||||
|
## [v0.50.81] — 2026-04-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Auto-title extraction improved for tool-heavy first turns** — sessions where the agent's first response involved tool calls (e.g. memory lookups, file reads) were generating poor titles because the title extractor skipped all assistant messages with `tool_calls`, even when those messages contained substantive visible text. The extractor now picks the first pure (non-tool-call) assistant reply as the title source, using `_looks_invalid_generated_title()` to distinguish meta-reasoning preambles from real agentic replies. Also fixes `_is_provisional_title()` to normalize whitespace before comparing, so CJK text truncated at 64 characters correctly re-triggers title updates. (Closes #639, PR #640 by @franksong2702)
|
||||||
|
|
||||||
|
|
||||||
## [v0.50.76] — 2026-04-17
|
## [v0.50.76] — 2026-04-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -125,17 +125,33 @@ def _message_text(value) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _first_exchange_snippets(messages):
|
def _first_exchange_snippets(messages):
|
||||||
"""Return (first_user_text, first_assistant_text) snippets for title generation."""
|
"""Return (first_user_text, first_assistant_text) snippets for title generation.
|
||||||
|
|
||||||
|
Prefer the first substantive assistant answer in the opening exchange,
|
||||||
|
skipping empty placeholders and assistant tool-call preambles.
|
||||||
|
"""
|
||||||
user_text = ''
|
user_text = ''
|
||||||
asst_text = ''
|
asst_text = ''
|
||||||
for m in messages or []:
|
for m in messages or []:
|
||||||
if not isinstance(m, dict):
|
if not isinstance(m, dict):
|
||||||
continue
|
continue
|
||||||
role = m.get('role')
|
role = m.get('role')
|
||||||
if role == 'user' and not user_text:
|
if role == 'user':
|
||||||
user_text = _message_text(m.get('content'))
|
|
||||||
elif role == 'assistant' and not asst_text:
|
|
||||||
candidate = _message_text(m.get('content'))
|
candidate = _message_text(m.get('content'))
|
||||||
|
if not user_text and candidate:
|
||||||
|
user_text = candidate
|
||||||
|
continue
|
||||||
|
if user_text and candidate:
|
||||||
|
break
|
||||||
|
elif role == 'assistant' and user_text:
|
||||||
|
candidate = _message_text(m.get('content'))
|
||||||
|
# Skip tool-call preambles *only* when content is empty or looks
|
||||||
|
# like meta-reasoning ("Let me check my memory first.", "The user
|
||||||
|
# is asking...", etc.). Assistant rows that carry tool_calls but
|
||||||
|
# also contain a substantive answer text are kept — those are
|
||||||
|
# agentic first-turn plans that are legitimate title candidates.
|
||||||
|
if m.get('tool_calls') and (not candidate or _looks_invalid_generated_title(candidate)):
|
||||||
|
continue
|
||||||
if candidate:
|
if candidate:
|
||||||
asst_text = candidate
|
asst_text = candidate
|
||||||
if user_text and asst_text:
|
if user_text and asst_text:
|
||||||
@@ -148,7 +164,11 @@ def _is_provisional_title(current_title: str, messages) -> bool:
|
|||||||
derived = title_from(messages, '') or ''
|
derived = title_from(messages, '') or ''
|
||||||
if not derived:
|
if not derived:
|
||||||
return False
|
return False
|
||||||
return (str(current_title or '').strip() == derived[:64])
|
current = re.sub(r'\s+', ' ', str(current_title or '')).strip()
|
||||||
|
candidate = re.sub(r'\s+', ' ', str(derived[:64] or '')).strip()
|
||||||
|
if not current or not candidate:
|
||||||
|
return False
|
||||||
|
return current == candidate or candidate.startswith(current)
|
||||||
|
|
||||||
|
|
||||||
def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]:
|
def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]:
|
||||||
|
|||||||
@@ -591,7 +591,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.80</span>
|
<span class="settings-version-badge">v0.50.81</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -213,6 +213,169 @@ class TestIssue495TitleStreaming(unittest.TestCase):
|
|||||||
"messages.js should close SSE connection on stream_end (not immediately on done)",
|
"messages.js should close SSE connection on stream_end (not immediately on done)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_title_snippet_uses_visible_assistant_reply_after_tools(self):
|
||||||
|
"""Tool-heavy opening turns should use the final visible assistant reply."""
|
||||||
|
from api.streaming import _first_exchange_snippets
|
||||||
|
|
||||||
|
user_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Please look up the earlier context and then summarize it.",
|
||||||
|
}
|
||||||
|
preamble_asst = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Let me check my memory first.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"function": {
|
||||||
|
"name": "memory",
|
||||||
|
"arguments": '{"action":"search"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
tool_result = {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call-1",
|
||||||
|
"content": '{"result":"background info"}',
|
||||||
|
}
|
||||||
|
final_asst = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Here is the substantive answer after the tool work.",
|
||||||
|
}
|
||||||
|
|
||||||
|
user_text, assistant_text = _first_exchange_snippets(
|
||||||
|
[user_msg, preamble_asst, tool_result, final_asst]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(user_text, user_msg["content"][:500])
|
||||||
|
self.assertEqual(assistant_text, final_asst["content"][:500])
|
||||||
|
|
||||||
|
def test_title_snippet_keeps_short_substantive_assistant_reply(self):
|
||||||
|
"""Short but real assistant answers should still be eligible for titles."""
|
||||||
|
from api.streaming import _first_exchange_snippets
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Can you help me rename this session?"},
|
||||||
|
{"role": "assistant", "content": "Sure."},
|
||||||
|
]
|
||||||
|
|
||||||
|
user_text, assistant_text = _first_exchange_snippets(messages)
|
||||||
|
|
||||||
|
self.assertEqual(user_text, "Can you help me rename this session?")
|
||||||
|
self.assertEqual(assistant_text, "Sure.")
|
||||||
|
|
||||||
|
def test_provisional_title_detection_ignores_whitespace_noise(self):
|
||||||
|
"""Temporary first-message titles should still match with whitespace normalization."""
|
||||||
|
from api.streaming import _is_provisional_title, title_from
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "过去两个礼拜发生了一些事情。最重要的一点就是我加入了一个 Hermes Web UI 的项目。\n\n因为我开始使用 Hermes 这个 agent 以后,就逐渐不再使用 OpenClaw了。",
|
||||||
|
},
|
||||||
|
{"role": "assistant", "content": "Sure, let me help."},
|
||||||
|
]
|
||||||
|
|
||||||
|
derived = title_from(messages, "")
|
||||||
|
current = derived[:63] # Simulate the provisional title the UI writes immediately.
|
||||||
|
|
||||||
|
self.assertNotEqual(current, derived[:64])
|
||||||
|
self.assertTrue(
|
||||||
|
_is_provisional_title(current, messages),
|
||||||
|
"Whitespace-normalized provisional titles should still be recognized",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_title_snippet_keeps_tool_call_with_substantive_text(self):
|
||||||
|
"""An assistant row with tool_calls AND a substantive answer text
|
||||||
|
must still be used as the first-exchange snippet — it's not a
|
||||||
|
preamble, it's an agentic first-turn plan."""
|
||||||
|
from api.streaming import _first_exchange_snippets
|
||||||
|
|
||||||
|
user_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Can you schedule a reminder for the Q3 kickoff meeting?",
|
||||||
|
}
|
||||||
|
# Assistant row with both a real answer AND a tool_call
|
||||||
|
agentic_asst = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I'll schedule the Q3 kickoff reminder for next Monday at 9am.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"function": {
|
||||||
|
"name": "cronjob",
|
||||||
|
"arguments": '{"action":"create","when":"mon 9am"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
user_text, assistant_text = _first_exchange_snippets([user_msg, agentic_asst])
|
||||||
|
|
||||||
|
self.assertEqual(user_text, user_msg["content"][:500])
|
||||||
|
self.assertEqual(
|
||||||
|
assistant_text,
|
||||||
|
agentic_asst["content"][:500],
|
||||||
|
"Substantive answer text on a tool_call row must be preserved",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_title_snippet_skips_tool_call_preamble_only_rows(self):
|
||||||
|
"""Tool-call rows whose content is empty or meta-reasoning preamble
|
||||||
|
('Let me check my memory first.') must still be skipped — those are
|
||||||
|
orchestration scaffolding, not title material."""
|
||||||
|
from api.streaming import _first_exchange_snippets
|
||||||
|
|
||||||
|
user_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Summarize my notes from last week.",
|
||||||
|
}
|
||||||
|
empty_preamble = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"function": {
|
||||||
|
"name": "memory",
|
||||||
|
"arguments": '{"action":"search"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
meta_preamble = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Let me check my memory first.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-2",
|
||||||
|
"function": {
|
||||||
|
"name": "memory",
|
||||||
|
"arguments": '{"action":"search","q":"last week"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
tool_result = {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call-2",
|
||||||
|
"content": '{"result":"background info"}',
|
||||||
|
}
|
||||||
|
final_asst = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Here's a summary of your notes from last week.",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, assistant_text = _first_exchange_snippets(
|
||||||
|
[user_msg, empty_preamble, meta_preamble, tool_result, final_asst]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
assistant_text,
|
||||||
|
final_asst["content"][:500],
|
||||||
|
"Empty and meta-reasoning preamble rows must be skipped",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user