diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 1d14cb6..82c76f1 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -4,6 +4,37 @@ --- +## Session 标题豁免前缀对 `[Session]` 兜底标题不生效(豁免仅 Level 1 生效) + +**问题描述** + +用户在 `session_policies.title_exempt_prefixes` 配置中加入 `"[Session]"`,期望过滤掉 `[Session] claude-opus-4-8` 这类不预期标题(此前已用同机制成功豁免 `Write the title in the language ...` 注入式 Prompt)。但配置后无效——`[Session] claude-opus-4-8` 仍被写入 Session 标题。 + +**表因** + +`[Session] claude-opus-4-8` **不是用户输入**,而是标题提取 Level 4 元数据兜底(`_extract_title_from_metadata`,`f"[Session] {request.model}"`)的产物。用户误以为它来自 user 文本,故尝试用 `title_exempt_prefixes` 豁免。 + +**根因** + +`_extract_session_title`(`routing/executor.py`)采用 4 层回退,但 `exempt_prefixes` **仅在 Level 1**(`_extract_title_from_user_text`)消费——这是原始设计:v0.5.2a1 #265 引入该配置时仅覆盖 L1,docstring 明写「Level 2/3/4 不参与豁免」。Level 2/3/4 的合成标题(`[Tool output]` / `[N Image]` / `[Tool call]` / `[Session]`)完全不查询豁免名单。故用户配的 `"[Session]"` 只进到 L1,对 L4 兜底标题毫无作用——这是「配了却不生效」的根本原因,**非配置错误**。 + +**处理方式** + +在 `_extract_session_title` 编排层引入 `_is_exempt` 闭包,对 L1/L2/L3/L4 每层候选统一做大小写敏感 `startswith` 拦截,命中则向下一层回退、全豁免返回空串。返回空串触发调用方 `if title:` 跳过写库,标题保持空,待后续请求带真实 user 文本时经 `update_empty_session_title` 回填。L1 内部「逐条消息跳过」语义不变,外层闭包作一致性兜底;并补入参空串前缀防御。**默认行为零影响**(不添加前缀则不豁免);**不**把合成前缀塞进默认 config,避免误杀正常 `[Tool output]` 标题。新增 8 个回归测试(含 mock recorder 验证写库跳过)。 + +**后续防范** + +- **新增合成兜底标题须纳入豁免链路**:未来若在 L2/L3/L4 增加新的 `f"[Xxx] ..."` 合成标题,编排层的 `_is_exempt` 自动覆盖(无需改子函数),但应在 `config.default.yaml` 注释里补充示例前缀,提示用户可豁免。 +- **空串前缀恒真陷阱**:`"".startswith` 恒真会豁免一切。配置层 field_validator、构造器、模块函数入参已三重过滤,任何新增的 startswith 豁免/绑定逻辑都须保留空串防御。 + +**同类问题影响与处理注意事项** + +- **`[Session] ` 触发条件**:请求无可提取的 user TEXT / TOOL_RESULT / IMAGE / tool_names,只剩 model 字段(典型如首条消息全是 `` 噪声、或仅 assistant 消息的续接请求)。排查此类标题应优先确认请求体内容,而非怀疑配置。 +- **豁免语义边界**:豁免 =「跳过该候选、继续向下一层回退」,非「替换为别的标题」。L4 是最后一层,被豁免只能落到空串;依赖标题触发 `_apply_title_based_policy` 供应商自动绑定的场景,空标题不会触发绑定(预期副作用,语义自洽)。 +- **不要把合成前缀写进默认配置**:`[Tool output]` 等对用户有信息量,默认豁免会造成正常工具结果摘要标题消失的回归。 + +--- + ## Zhipu 529 过载重试退避非单调(对齐 429 指数退避语义) **问题描述** diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index 52950e0..1504871 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -704,9 +704,11 @@ session_policies: title_vendor_bindings: - prefix: "# 目标" vendor: "zhipu" - # Session 标题豁免前缀:user 输入(经噪声剥离清洗后)以任一前缀开头则跳过, - # 继续向后查找 title 候选。用于过滤注入式 Prompt(如示例语言指令), - # 避免其被误用作 Session 标题。大小写敏感 startswith。 + # Session 标题豁免前缀:标题提取每一层(user 文本 / 工具结果 / 图片 / 元数据兜底) + # 产出的候选以任一前缀开头则跳过,继续向下一层回退。既用于过滤注入式 Prompt + # (如示例语言指令),也可豁免无信息量的合成兜底标题。大小写敏感 startswith。 + # 例:添加 "[Session]" 可让 [Session] 这类兜底标题不再写入, + # session 标题保持空,待后续真实输入经延迟补写回填。 # ⚠️ 自定义 yaml 中该字段为整体替换(loader 的 list 合并约定,非追加合并); # 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。 title_exempt_prefixes: diff --git a/src/coding/proxy/config/session_policy.py b/src/coding/proxy/config/session_policy.py index 7217f73..82aca1a 100644 --- a/src/coding/proxy/config/session_policy.py +++ b/src/coding/proxy/config/session_policy.py @@ -84,9 +84,12 @@ class SessionPoliciesConfig(BaseModel): title_exempt_prefixes: list[str] = Field( default_factory=list, description=( - "Session 标题豁免前缀名单。当首条 user TEXT 输入(经噪声剥离清洗后)" - "以任一前缀开头时,跳过该输入、继续向后查找合适的 title 候选。" - "用于过滤注入式 Prompt(如示例语言指令),避免其被误用作 Session 标题。" + "Session 标题豁免前缀名单。标题提取的每一层(user TEXT 噪声剥离、" + "TOOL_RESULT 摘要、IMAGE 计数、元数据兜底)产出的候选,若以任一前缀" + "开头,则视为豁免、继续向下一层回退;全部层级均被豁免时返回空串," + "调用方据此跳过写库,session 标题保持空待后续真实输入回填。" + "既用于过滤注入式 Prompt(如示例语言指令),也可豁免无信息量的合成" + "兜底标题(如 [Session]、[Tool call]、[Tool output])。" "大小写敏感的 startswith 匹配,与 title_vendor_bindings 语义一致。" "加载时会自动 strip + 去空 + 去重;空字符串前缀会被丢弃" "(防空串 startswith 恒真导致全量误豁免)。" diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 0569126..ea652ed 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -216,25 +216,45 @@ def _extract_session_title( """从规范化请求中提取 session 标题 — 多层级回退策略。 依次尝试: user TEXT 噪声剥离 → TOOL_RESULT 摘要 → IMAGE 计数 → 元数据兜底。 - 任意级别命中即返回,确保 Dashboard 尽可能展示有辨识度的标题。 - - ``exempt_prefixes`` 仅作用于 Level 1(用户直接输入的 TEXT);Level 2/3/4 - 为非用户直接输入的回退来源,不参与豁免。默认 ``None`` 时行为与历史一致。 + 任一层级产出候选且未被豁免即返回,确保 Dashboard 尽可能展示有辨识度的标题。 + + ``exempt_prefixes`` 对所有层级统一生效:任一层产出的候选标题若以任一前缀 + 开头,则视为豁免、继续向下一层回退。Level 1 内部仍保留「逐条消息跳过」 + 语义(清洗后文本以豁免前缀开头则跳过该条、继续查找下一条 user TEXT); + 本函数在编排层对每层最终候选再做一次 ``_is_exempt`` 拦截,使 Level 2/3/4 + 的合成标题(如 ``[Tool output]``、``[N Image(s)]``、``[Tool call]``、 + ``[Session]``)也能被用户显式豁免。全部层级均被豁免时返回空串,调用方 + ``if title:`` 据此跳过写库,session 标题保持空,待后续请求经 + ``update_empty_session_title`` 回填有意义标题。默认 ``None`` 时行为与历史一致。 """ + # 防御性过滤空串前缀:"" startswith 恒真会误豁免一切。入参虽经配置层与 + # 构造器两层归一化,模块级直接调用(如测试)仍可能传入 [""],此处兜底。 + prefixes = [p for p in (exempt_prefixes or []) if p] + + def _is_exempt(candidate: str) -> bool: + """候选标题是否命中任一豁免前缀(大小写敏感 startswith)。""" + return bool(prefixes) and any(candidate.startswith(p) for p in prefixes) + messages = request.messages - # Level 1 需要豁免前缀;单独调用以传入参数,避免污染 L2/L3 的统一签名。 - title = _extract_title_from_user_text(messages, exempt_prefixes) - if title: + # Level 1: 内部已对每条 user TEXT 做豁免跳过;外层 _is_exempt 对其最终候选 + # 再做一次拦截(正常路径下冗余,作为 _sanitize_user_text 一致性兜底)。 + title = _extract_title_from_user_text(messages, exempt_prefixes=prefixes) + if title and not _is_exempt(title): return title[:_SESSION_TITLE_MAX_LEN] + # Level 2 / Level 3: 候选为合成标题,统一经 _is_exempt 拦截后回退。 for extractor in ( _extract_title_from_tool_results, _extract_title_from_images, ): title = extractor(messages) - if title: + if title and not _is_exempt(title): return title[:_SESSION_TITLE_MAX_LEN] - # Level 4 依赖 request 元数据,签名不同 - return _extract_title_from_metadata(request)[:_SESSION_TITLE_MAX_LEN] + # Level 4: 签名不同(依赖 request 元数据)。被豁免则落到末尾返回空串, + # 使调用方 if title: 跳过写库、保留空标题待后续回填。 + title = _extract_title_from_metadata(request) + if title and not _is_exempt(title): + return title[:_SESSION_TITLE_MAX_LEN] + return "" def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 4077271..837edcc 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -9,7 +9,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -2483,6 +2483,97 @@ def test_exempt_does_not_affect_l2(self): # L2 不受豁免影响,正常提取并加 [Tool output] 前缀 assert title == "[Tool output] Write the title in the language ..." + # ── 豁免前缀对合成兜底标题(Level 2/3/4)统一生效 ── + + def test_exempt_l4_session_title_returns_empty(self): + """L4 兜底 [Session] 标题命中豁免前缀 → 回退耗尽 → 返回空串.""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + # L1 噪声剥离为空 → L4 产出 "[Session] test" → 命中 "[Session]" 豁免 + assert _extract_session_title(req, exempt_prefixes=["[Session]"]) == "" + + def test_exempt_l4_tool_call_title_returns_empty(self): + """L4 兜底 [Tool call] 标题命中豁免前缀 → 返回空串.""" + req = build_canonical_request( + {"model": "test", "messages": [], "tools": [{"name": "Bash"}]}, {} + ) + assert _extract_session_title(req, exempt_prefixes=["[Tool call]"]) == "" + + def test_exempt_l4_unmatched_keeps_original(self): + """豁免前缀不命中 L4 实际标题时维持原行为(不误豁免).""" + req = build_canonical_request( + {"model": "test", "messages": [], "tools": [{"name": "Bash"}]}, {} + ) + # L4 产出 "[Tool call] Bash",豁免前缀 "[Session]" 不命中 → 原样返回 + assert ( + _extract_session_title(req, exempt_prefixes=["[Session]"]) + == "[Tool call] Bash" + ) + + def test_exempt_l2_tool_output_falls_through_to_l4(self): + """L2 合成标题 [Tool output] 命中豁免 → 跳过 L2、回退到 L4.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [{"type": "text", "text": "文件内容摘要"}], + } + ], + }, + ] + req = self._build_request(messages) + # L1 无 user TEXT → L2 "[Tool output] 文件内容摘要" 命中豁免 → 跳过 → + # L3 无图 → L4 "[Session] test"(未命中豁免,正常返回) + assert ( + _extract_session_title(req, exempt_prefixes=["[Tool output]"]) + == "[Session] test" + ) + + def test_exempt_l3_image_falls_through_to_l4(self): + """L3 合成标题 [1 Image] 命中豁免 → 跳过 L3、回退到 L4.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + } + ], + } + ] + req = self._build_request(messages) + assert ( + _extract_session_title(req, exempt_prefixes=["[1 Image]"]) + == "[Session] test" + ) + + def test_exempt_all_synthetic_layers_returns_empty(self): + """四层合成标题全部进豁免名单 → 全部被拦截 → 返回空串(永久空标题可接受).""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + assert ( + _extract_session_title( + req, + exempt_prefixes=[ + "[Tool output]", + "[1 Image]", + "[Tool call]", + "[Session]", + ], + ) + == "" + ) + # ═══════════════════════════════════════════════════════════════════ # 多层级回退标题提取测试 @@ -3191,3 +3282,32 @@ def test_executor_filters_empty_string_prefix_at_construction(self): executor = _executor(title_exempt_prefixes=["", " ", "\t"]) assert executor._title_exempt_prefixes == [] assert executor._extract_session_title(req) == "正常标题" + + def test_executor_exempt_l4_returns_empty(self): + """executor 持有 [Session] 豁免 → L4 兜底标题被豁免 → 实例方法返回空串.""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + executor = _executor(title_exempt_prefixes=["[Session]"]) + # L1 噪声剥离为空 → L4 "[Session] test" 命中豁免 → 返回 "" + assert executor._extract_session_title(req) == "" + + @pytest.mark.asyncio + async def test_execute_skips_title_write_when_l4_exempt(self): + """端到端: L4 兜底 [Session] 标题命中豁免 → 标题为空 → set_session_title 不被调用.""" + recorder = UsageRecorder() + executor = _executor( + recorder=recorder, + session_mgr=_stub_session_manager(is_new=True), + title_exempt_prefixes=["[Session]"], + ) + body = { + "model": "claude-opus-4-8", + "metadata": {"user_id": "session-l4"}, + "messages": [ + {"role": "user", "content": "纯噪声"} + ], + } + with patch.object(recorder, "set_session_title", new=AsyncMock()) as spy: + await executor.execute_message(body, {}) + spy.assert_not_called()