Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/.agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <model>` 触发条件**:请求无可提取的 user TEXT / TOOL_RESULT / IMAGE / tool_names,只剩 model 字段(典型如首条消息全是 `<system-reminder>` 噪声、或仅 assistant 消息的续接请求)。排查此类标题应优先确认请求体内容,而非怀疑配置。
- **豁免语义边界**:豁免 =「跳过该候选、继续向下一层回退」,非「替换为别的标题」。L4 是最后一层,被豁免只能落到空串;依赖标题触发 `_apply_title_based_policy` 供应商自动绑定的场景,空标题不会触发绑定(预期副作用,语义自洽)。
- **不要把合成前缀写进默认配置**:`[Tool output]` 等对用户有信息量,默认豁免会造成正常工具结果摘要标题消失的回归。

---

## Zhipu 529 过载重试退避非单调(对齐 429 指数退避语义)

**问题描述**
Expand Down
8 changes: 5 additions & 3 deletions src/coding/proxy/config/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -704,9 +704,11 @@ session_policies:
title_vendor_bindings:
- prefix: "# 目标"
vendor: "zhipu"
# Session 标题豁免前缀:user 输入(经噪声剥离清洗后)以任一前缀开头则跳过,
# 继续向后查找 title 候选。用于过滤注入式 Prompt(如示例语言指令),
# 避免其被误用作 Session 标题。大小写敏感 startswith。
# Session 标题豁免前缀:标题提取每一层(user 文本 / 工具结果 / 图片 / 元数据兜底)
# 产出的候选以任一前缀开头则跳过,继续向下一层回退。既用于过滤注入式 Prompt
# (如示例语言指令),也可豁免无信息量的合成兜底标题。大小写敏感 startswith。
# 例:添加 "[Session]" 可让 [Session] <model> 这类兜底标题不再写入,
# session 标题保持空,待后续真实输入经延迟补写回填。
# ⚠️ 自定义 yaml 中该字段为整体替换(loader 的 list 合并约定,非追加合并);
# 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。
title_exempt_prefixes:
Expand Down
9 changes: 6 additions & 3 deletions src/coding/proxy/config/session_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 恒真导致全量误豁免)。"
Expand Down
40 changes: 30 additions & 10 deletions src/coding/proxy/routing/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 121 additions & 1 deletion tests/test_router_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<system-reminder>纯噪声</system-reminder>"}]
)
# 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": "<system-reminder>纯噪声</system-reminder>"}]
)
assert (
_extract_session_title(
req,
exempt_prefixes=[
"[Tool output]",
"[1 Image]",
"[Tool call]",
"[Session]",
],
)
== ""
)


# ═══════════════════════════════════════════════════════════════════
# 多层级回退标题提取测试
Expand Down Expand Up @@ -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": "<system-reminder>纯噪声</system-reminder>"}]
)
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": "<system-reminder>纯噪声</system-reminder>"}
],
}
with patch.object(recorder, "set_session_title", new=AsyncMock()) as spy:
await executor.execute_message(body, {})
spy.assert_not_called()
Loading