mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:13:19 +08:00
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:
parent
16169b3de2
commit
9f4fa3d319
5 changed files with 153 additions and 28 deletions
|
|
@ -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])
|
||||
|
|
|
|||
61
runtime/session_auto_title.py
Normal file
61
runtime/session_auto_title.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue