fix(chat): harden session auto-title and keep a single no-tool LLM call

- Add session_auto_title prompts, finalize_auto_title (reject prose, truncate, fallback).
- Third-round naming: one model.chat(system, user) with empty tools; reuse executor.model.
- Extend worker title tests for garbage model output.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-06 15:19:22 +08:00
parent 16169b3de2
commit 9f4fa3d319
5 changed files with 153 additions and 28 deletions

View file

@ -36,6 +36,11 @@ from oclaw.runtime.worker import ensure_worker_started
from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id
from oclaw.runtime.chat.tool_runtime import compact_turn_tool_messages_for_storage
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
from oclaw.runtime.session_auto_title import (
AUTO_TITLE_SYSTEM_PROMPT_EN,
AUTO_TITLE_SYSTEM_PROMPT_ZH,
finalize_auto_title,
)
from oclaw.runtime.tools.base import ToolRegistry
from oclaw.runtime.tools.local_sdk import local_adapter_startup_self_check
@ -180,7 +185,7 @@ class OclawGateway:
}
def _maybe_generate_title_on_third_round(self, *, msg: StandardMessage, model: Any | None) -> None:
"""Generate title once on round-3 using user text only (no tools/reasoning context)."""
"""Generate title once on round-3: one plain model.chat (system+user, no tools)."""
if model is None or not callable(getattr(model, "chat", None)):
return
sid = str(msg.session_id or "").strip()
@ -225,19 +230,12 @@ class OclawGateway:
body = body[:_TITLE_BODIES_MAX_CHARS]
try:
lang_is_en = str(msg.metadata.get("lang") if isinstance(msg.metadata, dict) else "").lower().startswith("en")
sys = (
"Generate a concise chat title from these user messages only. "
"Use the dominant language used by the user content body. "
"Return title text only, no quotes, no markdown, max 18 chars."
if lang_is_en
else "仅基于以下用户正文生成简短会话标题。请使用对话内容主体语言命名。"
"只返回标题文本,不要引号,不要markdown,最多18个字。"
)
sys = AUTO_TITLE_SYSTEM_PROMPT_EN if lang_is_en else AUTO_TITLE_SYSTEM_PROMPT_ZH
messages = [{"role": "system", "content": sys}, {"role": "user", "content": body}]
ensure_no_tool_or_embedded_image_payload(messages=messages, path="gateway.auto_title")
resp = model.chat(messages, [], on_token=None)
title = str(getattr(resp, "content", "") or "").strip().replace("\n", " ")
title = title.strip("\"'` ").strip()
raw_title = str(getattr(resp, "content", "") or "").strip().strip("\"'` ")
title = finalize_auto_title(raw=raw_title, fallback=str(bodies[0] or "").strip())
if not title:
return
self.store.rename_session(sid, title[:_SESSION_TITLE_MAX_LEN])

View file

@ -0,0 +1,61 @@
from __future__ import annotations
AUTO_TITLE_CHAR_MAX = 18
# Minimal system prompts: naming-only role (full rules are enforced in finalize_auto_title).
AUTO_TITLE_SYSTEM_PROMPT_EN = (
"Dedicated session naming agent: output one short title only, "
f"≤{AUTO_TITLE_CHAR_MAX} characters, same language as the user lines."
)
AUTO_TITLE_SYSTEM_PROMPT_ZH = (
"你是专职会话命名助手:只输出一条短标题,"
f"至多{AUTO_TITLE_CHAR_MAX}个字,语种与用户发言一致。"
)
# One-line titles longer than this are almost never valid (model wrote prose).
_AUTO_TITLE_RAW_SOFT_MAX = 42
def _collapse_ws(raw: str) -> str:
return " ".join(str(raw or "").strip().split())
def should_reject_auto_title(raw: str) -> bool:
s = _collapse_ws(raw)
if not s:
return True
if len(s) > _AUTO_TITLE_RAW_SOFT_MAX:
return True
low = s.lower()
if "claude code" in low:
return True
if "软件工程助手" in s:
return True
if "您好" in s and "我是" in s and len(s) > 16:
return True
if low.startswith("hello! i am") or low.startswith("hi! i am"):
return True
return False
def finalize_auto_title(*, raw: str, fallback: str, max_chars: int = AUTO_TITLE_CHAR_MAX) -> str:
"""Turn model output into a short session title; reject prose and use fallback."""
fb = _collapse_ws(fallback)
pick = _collapse_ws(raw)
if not pick or should_reject_auto_title(raw):
base = fb
else:
base = pick
if not base:
return "会话"
if len(base) <= max_chars:
return base
return base[:max_chars]
__all__ = [
"AUTO_TITLE_CHAR_MAX",
"AUTO_TITLE_SYSTEM_PROMPT_EN",
"AUTO_TITLE_SYSTEM_PROMPT_ZH",
"finalize_auto_title",
"should_reject_auto_title",
]

View file

@ -12,6 +12,11 @@ from oclaw.runtime.memory_stage import build_memory_context
from oclaw.runtime.relay_pointer import build_acp_relay_result, validate_relay_share_envelope
from oclaw.runtime.types import StandardMessage
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
from oclaw.runtime.session_auto_title import (
AUTO_TITLE_SYSTEM_PROMPT_EN,
AUTO_TITLE_SYSTEM_PROMPT_ZH,
finalize_auto_title,
)
_LOCK = threading.Lock()
_THREAD: threading.Thread | None = None
@ -68,7 +73,7 @@ def _maybe_rename_from_first_user_message(*, store: Any, session_id: str, user_t
def _maybe_generate_title_on_third_round(*, store: Any, msg: StandardMessage, model: Any | None) -> None:
"""Generate title once on round-3 using user text only (no tools/reasoning context)."""
"""Generate title once on round-3: one plain model.chat (system+user, no tools)."""
if model is None or not callable(getattr(model, "chat", None)):
return
sid = str(msg.session_id or "").strip()
@ -113,19 +118,12 @@ def _maybe_generate_title_on_third_round(*, store: Any, msg: StandardMessage, mo
body = body[:_TITLE_BODIES_MAX_CHARS]
try:
lang_is_en = str(msg.metadata.get("lang") if isinstance(msg.metadata, dict) else "").lower().startswith("en")
sys = (
"Generate a concise chat title from these user messages only. "
"Use the dominant language used by the user content body. "
"Return title text only, no quotes, no markdown, max 18 chars."
if lang_is_en
else "仅基于以下用户正文生成简短会话标题。请使用对话内容主体语言命名。"
"只返回标题文本,不要引号,不要markdown,最多18个字。"
)
sys = AUTO_TITLE_SYSTEM_PROMPT_EN if lang_is_en else AUTO_TITLE_SYSTEM_PROMPT_ZH
messages = [{"role": "system", "content": sys}, {"role": "user", "content": body}]
ensure_no_tool_or_embedded_image_payload(messages=messages, path="worker.auto_title")
resp = model.chat(messages, [], on_token=None)
title = str(getattr(resp, "content", "") or "").strip().replace("\n", " ")
title = title.strip("\"'` ").strip()
raw_title = str(getattr(resp, "content", "") or "").strip().strip("\"'` ")
title = finalize_auto_title(raw=raw_title, fallback=str(bodies[0] or "").strip())
if not title:
return
store.rename_session(sid, title[:_SESSION_TITLE_MAX_LEN])
@ -284,11 +282,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
user_text=user_text,
attachments=list(attachments or []),
)
_maybe_generate_title_on_third_round(
store=store,
msg=msg,
model=getattr(executor, "model", None),
)
_maybe_generate_title_on_third_round(store=store, msg=msg, model=getattr(executor, "model", None))
run_out = run_agent_core(
store=store,
data=AgentCoreRunInput(

View file

@ -14,8 +14,11 @@ class _DummyResp:
class _DummyModel:
def __init__(self, content: str = "第三轮标题") -> None:
self._content = content
def chat(self, _messages, _tools, on_token=None): # noqa: ANN001
return _DummyResp("第三轮标题")
return _DummyResp(self._content)
def test_worker_third_round_title_generation_updates_stage3(tmp_path: Path) -> None:
@ -57,3 +60,45 @@ def test_worker_third_round_title_generation_updates_stage3(tmp_path: Path) -> N
assert renamed is not None
assert str(getattr(renamed, "title", "") or "") == "第三轮标题"
assert str(store.get_setting(f"AIA_SESSION_AUTO_TITLE_STAGE:{sid}") or "") == "3"
def test_worker_third_round_rejects_prose_title_uses_fallback(tmp_path: Path) -> None:
store = SqliteStore(str(tmp_path / "worker-title-fb.sqlite"))
tenant = store.create_tenant("Team")
user = store.create_user_account(
tenant_id=str(tenant["id"]),
username="tester",
display_name="Tester",
role="owner",
password_hash=hashlib.sha256("test-pass".encode("utf-8")).hexdigest(),
is_active=True,
)
session = store.create_session_for_user(
title="第一轮标题",
tenant_id=str(tenant["id"]),
user_id=str(user["id"]),
)
sid = str(session.id)
store.set_setting(f"AIA_SESSION_AUTO_TITLE_STAGE:{sid}", "1")
store.add_message(session_id=sid, role="user", content="第一轮问题")
store.add_message(session_id=sid, role="assistant", content="第一轮回答")
store.add_message(session_id=sid, role="user", content="第二轮问题")
store.add_message(session_id=sid, role="assistant", content="第二轮回答")
msg = StandardMessage(
session_id=sid,
tenant_id=str(tenant["id"]),
user_id=str(user["id"]),
role="member",
channel="admin_chat",
text="第三轮问题",
attachments=[],
metadata={"lang": "zh"},
)
garbage = "您好!我是 Claude Code,一个软件工程助手。" + "x" * 80
_maybe_generate_title_on_third_round(store=store, msg=msg, model=_DummyModel(content=garbage))
renamed = store.get_session(sid)
assert renamed is not None
assert str(getattr(renamed, "title", "") or "") == "第一轮问题"
assert str(store.get_setting(f"AIA_SESSION_AUTO_TITLE_STAGE:{sid}") or "") == "3"

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from oclaw.runtime.session_auto_title import AUTO_TITLE_CHAR_MAX, finalize_auto_title, should_reject_auto_title
def test_should_reject_long_prose() -> None:
blob = "您好!我是 Claude Code,一个软件工程助手。" + "x" * 80
assert should_reject_auto_title(blob) is True
def test_should_reject_claude_code_intro() -> None:
assert should_reject_auto_title("Hello! I am Claude Code, a software engineering assistant.") is True
def test_should_accept_short_title() -> None:
assert should_reject_auto_title("告警查询配置") is False
def test_finalize_truncates_fallback() -> None:
long_fb = "一二三四五六七八九十11121314151617181920"
out = finalize_auto_title(raw="x" * 100, fallback=long_fb)
assert len(out) == AUTO_TITLE_CHAR_MAX
assert out == long_fb[:AUTO_TITLE_CHAR_MAX]
def test_finalize_uses_model_when_sane() -> None:
assert finalize_auto_title(raw=" 网管同步 ", fallback="fallback") == "网管同步"