diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e1301..cd8dbeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.62.9 — 2026-08-06 + +- 长对话不再把普通历史思考过程重复发送给模型,减少无效上下文占用;工具调用需要的推理状态仍会按模型协议保留。 +- 对话顶部上下文环改按实际模型输入统计,悬停时可查看剥离的历史思考、当前压缩的旧工具输出和累计整理次数;超出可靠容量时显示 `100%+`。 +- 新对话自动命名遇到模型服务暂时不可用时,会使用首条消息生成本地兜底标题,不再长期停留为“新对话”。 + ## 0.62.8 — 2026-08-06 - 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。 diff --git a/DESIGN.md b/DESIGN.md index 50f9c4a..eacc346 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,7 +127,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听 默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。 -模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度;`core/llm_params.py` 是请求构造唯一入口。DeepSeek、GLM、方舟当前共享 `extra_body` 协议,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`。 +模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度,`reasoning_replay` 只表示历史 reasoning 的 provider 回传策略(`none` / `tool_calls` / `all`);`core/llm_params.py` 是请求参数构造唯一入口,`core/context.py` 是历史消息清洗唯一入口。原始 assistant 响应仍完整落库供展示与导出,发模型前才按 profile 裁剪,且上下文统计、压缩与折叠都使用裁剪后的请求视图。DeepSeek、GLM、方舟当前共享 `extra_body` 协议,DeepSeek V4 仅为带工具调用的 assistant 消息保留 reasoning,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`。 --- @@ -171,7 +171,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听 两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 -**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB,首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir,后端据此判定自动命名,以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题,working_dir 仍可省略并 fallback 到 name,旧 `auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir;人工 PATCH name 同时清 pending,条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口,UI 同样要求明确选择 working_dir,name 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run。 +**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB,首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir,后端据此判定自动命名,以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题,working_dir 仍可省略并 fallback 到 name,旧 `auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir;人工 PATCH name 同时清 pending,条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口,UI 同样要求明确选择 working_dir,name 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`;模型调用失败时以首条消息第一行生成本地兜底标题,不阻塞主 run,也不把 pending 留给后续消息误命名。 **对话产物引用(0025)**:真实文件仍是事实源,不建 artifacts 表;`messages.artifact_refs` 只保存可重建的轻量 UI 元数据,规范路径以该 task 的**当前 working_dir 为根**,形如 `{version:1, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`。预览/下载走 task-scoped 文件 API,服务端用 task 当前 `working_dir` 解析,因此顶层工作目录改名后历史卡片仍有效。普通源码树、中间文件和配套资源只留文件面板;agent 仅用 `publish_artifacts` 显式提升少量最终文件,单条消息最多 10 个,图像/视频/Office 转 PDF 等成品工具可自动提升。`NULL` 表示迁移前旧消息,前端继续使用正文路径抽取,并在 task-scoped API 上启用只读兼容链(旧 user-root 含义→原样 task-relative→去掉旧目录前缀);新消息写 `[]` 或结构化列表,停止启发式抽取,避免重复卡片与误识别。文件在 working_dir 内再次移动或删除后引用可失效,这是 FS 事实源语义,不复制文件、不引不可变对象存储。 diff --git a/PROGRESS.md b/PROGRESS.md index acb5851..49b420d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-06(文件顶栏添加操作收拢,bump 0.62.8) +最后更新:2026-08-06(上下文 reasoning 回传治理与任务标题兜底,bump 0.62.9) --- @@ -23,6 +23,7 @@ ### 2026-08-06 +- **08-06 / 0.62.9 / reasoning 回传治理 + 上下文环口径统一**:原始 assistant reasoning 继续完整落库供展示/导出,provider-bound 请求按模型档案选择性剥离;DeepSeek V4 仅保留工具调用 reasoning,普通跨轮与模型切换不再携带私有状态。任务详情、SSE、压缩和折叠统一使用清洗后视图,顶部环悬停补充 reasoning 剥离数、当前压缩工具消息数与累计整理次数,超可靠容量封顶显示 `100%+` 并保留真实百分比。自动标题模型调用失败时改用首条消息首行本地兜底并一次性消费 pending;新增设计院工程图纸知识库与智能设计辅助系统调研文档。相关 85 项上下文/前端/循环 unittest 全绿、1 项测试库门控安全跳过,标题专项、Python 编译、JavaScript 语法及 diff 检查通过;无 schema、migration、依赖或运行方式变化,未连接生产 DB。 - **08-06 / 0.62.8 / 文件顶栏添加操作收拢**:把新建文件夹、从其他目录选入和上传文件三个同类入口合并为“+ 添加”菜单,`.*` 隐藏目录开关、刷新与收起继续常驻,顶栏操作由 6 个减至 4 个;拖拽上传和各操作原有行为不变。相关静态前端与无 DB 路由共 33 项 unittest、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。 - **08-06 / 0.62.7 / 文件面板先备料 + 隐藏开关常驻**:文件栏 `.*` 开关不再随浏览路径消失,但后端继续保证 user_root 平台点目录永不展示;新增“+”入口和 `POST /v1/files/mkdir`,可在根目录或当前目录创建直接子目录,创建成功后自动进入并刷新新对话目录候选,形成“建目录→上传/选入→开新对话”流程。目录名复用 working_dir 校验,点目录、路径式名称与重名分别拒绝;相关静态前端及无 DB 路由共 33 项 unittest、JavaScript 语法、Ruff 致命规则和 diff 检查通过。无 schema、migration 或依赖变化,未连接生产 DB。 - **08-06 / 0.62.6 / 模型标签禁止断行**:对话顶部模型控件增加不可断行约束,中栏宽度收紧时优先压缩下拉框,不再把“模型”两个汉字拆成两行;静态前端与更新日志共 12 项 unittest、diff 检查通过。纯样式修复,无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。 diff --git a/config/models/deepseek_v4.yaml b/config/models/deepseek_v4.yaml index 1e61ddb..3a5d6aa 100644 --- a/config/models/deepseek_v4.yaml +++ b/config/models/deepseek_v4.yaml @@ -16,6 +16,7 @@ variants: thinking_transport: extra_body reasoning_effort_levels: [low, high, max] default_reasoning_effort: high + reasoning_replay: tool_calls # 普通跨轮剥离;工具调用 reasoning 按官方协议完整回传 code_quality: good enable_run_python: true max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard @@ -41,6 +42,7 @@ variants: thinking_transport: extra_body reasoning_effort_levels: [low, medium, high, max] default_reasoning_effort: medium + reasoning_replay: tool_calls code_quality: excellent enable_run_python: true max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard diff --git a/core/__init__.py b/core/__init__.py index 9ecc4dd..b45a5e3 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.62.8" +__version__ = "0.62.9" diff --git a/core/capabilities.py b/core/capabilities.py index e355655..98aee6b 100644 --- a/core/capabilities.py +++ b/core/capabilities.py @@ -9,6 +9,8 @@ import yaml from .llm_params import THINKING_TRANSPORTS +REASONING_REPLAY_POLICIES = {"none", "tool_calls", "all"} + @dataclass class ModelCapabilities: @@ -32,6 +34,9 @@ class ModelCapabilities: thinking_transport: str = "none" reasoning_effort_levels: List[str] = field(default_factory=list) default_reasoning_effort: str = "" + # 历史 reasoning 发回模型的策略。持久化原始响应与 provider-bound 输入分离: + # none=全部剥离;tool_calls=仅工具调用 assistant 保留;all=完整原样回传。 + reasoning_replay: str = "none" # 代码 / 沙盒 code_quality: str = "good" @@ -86,6 +91,11 @@ class ModelCapabilities: f"档案 {path} 的 thinking_transport={caps.thinking_transport!r} 无效;" f"可选: {sorted(THINKING_TRANSPORTS)}" ) + if caps.reasoning_replay not in REASONING_REPLAY_POLICIES: + raise ValueError( + f"档案 {path} 的 reasoning_replay={caps.reasoning_replay!r} 无效;" + f"可选: {sorted(REASONING_REPLAY_POLICIES)}" + ) if caps.thinking_enabled and caps.thinking_transport == "none": raise ValueError( f"档案 {path} 开启 thinking 时必须声明可验证的 thinking_transport" diff --git a/core/context.py b/core/context.py index 105ebbb..5b966fd 100644 --- a/core/context.py +++ b/core/context.py @@ -29,6 +29,73 @@ COMPACT_CONTEXT_RATIO = 0.5 RATIO_MIN = 1.0 RATIO_MAX = 4.0 +_REASONING_FIELDS = { + "reasoning_content", + "thinking", + "thinking_blocks", + "redacted_thinking", +} + + +def filter_reasoning_for_replay( + messages: List[dict[str, Any]], + policy: str = "none", +) -> tuple[List[dict[str, Any]], dict[str, int]]: + """构造 provider-bound 消息副本,按能力档案裁剪历史 reasoning。 + + 原始 Session/DB payload 不动,继续供 UI、导出和排障使用。工具调用需要 reasoning + 连续性的 provider 可用 ``tool_calls``;未声明能力的 provider 默认 ``none``,避免 + 跨模型发送私有状态。保留时把 LiteLLM 可能放在 provider_specific_fields 里的 + reasoning_content 提升到 DeepSeek/OpenAI-compatible 所需的顶层字段。 + """ + if policy not in {"none", "tool_calls", "all"}: + raise ValueError(f"unsupported reasoning replay policy: {policy!r}") + + filtered: List[dict[str, Any]] = [] + stripped_messages = 0 + stripped_chars = 0 + for message in messages: + new_msg = deepcopy(message) + if policy == "all": + filtered.append(new_msg) + continue + preserve = ( + policy == "tool_calls" + and new_msg.get("role") == "assistant" + and bool(new_msg.get("tool_calls")) + ) + if preserve: + provider_fields = new_msg.get("provider_specific_fields") + if ( + "reasoning_content" not in new_msg + and isinstance(provider_fields, dict) + and provider_fields.get("reasoning_content") is not None + ): + new_msg["reasoning_content"] = provider_fields["reasoning_content"] + filtered.append(new_msg) + continue + + before = _message_chars(new_msg) + removed = False + for field in _REASONING_FIELDS: + if field in new_msg: + new_msg.pop(field) + removed = True + # LiteLLM 用该容器承载不同供应商私有响应字段;它不是通用聊天协议,模型切换 + # 或普通跨轮时不应原样透传。需要连续性的工具消息已在 preserve 分支返回。 + if "provider_specific_fields" in new_msg: + new_msg.pop("provider_specific_fields") + removed = True + after = _message_chars(new_msg) + if removed: + stripped_messages += 1 + stripped_chars += max(0, before - after) + filtered.append(new_msg) + return filtered, { + "stripped_reasoning_messages": stripped_messages, + "stripped_reasoning_chars": stripped_chars, + } + def clamp_ratio(ratio: float) -> float: """把 chars/token 比值夹进合理带宽。""" @@ -174,11 +241,13 @@ def prepare_messages_for_llm( keep_recent: int = 12, old_tool_chars: int = 2_000, compact_threshold_chars: int = 0, + reasoning_replay: str = "none", ) -> List[dict[str, Any]]: """返回发给 LLM 的 messages 副本。 - system 和最近 keep_recent 条消息原样保留。 - 较旧且过长的 tool content 压缩为头尾摘要。 + - assistant reasoning 按模型能力档案裁剪,原始 Session/DB 历史不变。 - assistant 的 tool_call.arguments 一律原样保留(改写会毒化模型,见模块注释)。 - role/tool_call_id/name 等协议字段不变。 """ @@ -187,6 +256,7 @@ def prepare_messages_for_llm( keep_recent=keep_recent, old_tool_chars=old_tool_chars, compact_threshold_chars=compact_threshold_chars, + reasoning_replay=reasoning_replay, ) return prepared @@ -197,15 +267,19 @@ def prepare_messages_with_stats( keep_recent: int = 12, old_tool_chars: int = 2_000, compact_threshold_chars: int = 0, + reasoning_replay: str = "none", ) -> tuple[List[dict[str, Any]], dict[str, int]]: """返回发给 LLM 的 messages 副本和压缩统计。 - `compact_threshold_chars`:上下文压力门槛。总体量(原始 chars)未超过它时**完全不压缩** + `reasoning_replay`:provider 对历史 reasoning 的回传要求;清洗先于体量统计与压缩。 + + `compact_threshold_chars`:上下文压力门槛。总体量(清洗后 chars)未超过它时**完全不压缩** —— 短任务不丢旧工具细节,且 prompt 前缀逐轮字节一致、DeepSeek 等前缀缓存全程命中。 默认 0 = 永远压缩(向后兼容)。caller(loop)按模型 reliable_context 折算传入。 """ if keep_recent < 0: keep_recent = 0 + messages, reasoning_stats = filter_reasoning_for_replay(messages, reasoning_replay) # 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。 messages, repaired_tool_calls = _repair_dangling_tool_calls(messages) original_chars = sum(_message_chars(m) for m in messages) @@ -221,6 +295,7 @@ def prepare_messages_with_stats( "compacted_skill_messages": 0, "compaction_skipped": 1, "repaired_tool_calls": repaired_tool_calls, + **reasoning_stats, } return unchanged, stats @@ -259,5 +334,6 @@ def prepare_messages_with_stats( "compacted_skill_messages": compacted_skill_messages, "compaction_skipped": 0, "repaired_tool_calls": repaired_tool_calls, + **reasoning_stats, } return prepared, stats diff --git a/core/context_fold.py b/core/context_fold.py index b9e9e7e..56f0efd 100644 --- a/core/context_fold.py +++ b/core/context_fold.py @@ -30,6 +30,7 @@ from .context import ( COMPACT_CONTEXT_RATIO, _message_chars, estimate_window_tokens, + filter_reasoning_for_replay, prepare_messages_with_stats, ) from .storage import session_scope @@ -142,12 +143,14 @@ def maybe_fold( 偏保守,原文全在 DB,可接受,不为此加持久化状态。 """ msgs = session.messages - before_chars = sum(_message_chars(m) for m in msgs) - est_tokens = estimate_window_tokens(msgs, session.last_measured_usage()) + reasoning_replay = getattr(caps, "reasoning_replay", "none") + provider_msgs, _ = filter_reasoning_for_replay(msgs, reasoning_replay) + before_chars = sum(_message_chars(m) for m in provider_msgs) + est_tokens = estimate_window_tokens(provider_msgs, session.last_measured_usage()) if est_tokens < caps.reliable_context * FOLD_TRIGGER_RATIO: return None head_len = session.context_head_len - cutoff = find_cutoff(msgs, head_len) + cutoff = find_cutoff(provider_msgs, head_len) if cutoff is None: return None folded_count = cutoff - head_len @@ -157,7 +160,11 @@ def maybe_fold( # 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐: # prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。 compact_threshold = int(caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN) - prepared, _ = prepare_messages_with_stats(msgs, compact_threshold_chars=compact_threshold) + prepared, _ = prepare_messages_with_stats( + msgs, + compact_threshold_chars=compact_threshold, + reasoning_replay=reasoning_replay, + ) p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff)) if p_cut is None: # 理论不可达,防御 return None @@ -192,7 +199,10 @@ def maybe_fold( new_base = session.context_base + folded_count persist_fold(session.task_id, new_base, summary) session.apply_fold(cutoff, summary) - after_chars = sum(_message_chars(m) for m in session.messages) + after_provider_msgs, _ = filter_reasoning_for_replay( + session.messages, reasoning_replay + ) + after_chars = sum(_message_chars(m) for m in after_provider_msgs) stats = { "type": "context_fold", "phase": "done", diff --git a/core/loop.py b/core/loop.py index 397b385..2e38f85 100644 --- a/core/loop.py +++ b/core/loop.py @@ -26,6 +26,7 @@ from .context import ( CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, calibrated_chars_per_token, + filter_reasoning_for_replay, clamp_ratio, prepare_messages_with_stats, ) @@ -458,8 +459,12 @@ class AgentLoop: """ if self._ctx_chars_per_token is None: try: + provider_messages, _ = filter_reasoning_for_replay( + self.session.messages, + getattr(self.caps, "reasoning_replay", "none"), + ) self._ctx_chars_per_token = calibrated_chars_per_token( - self.session.messages, self.session.last_measured_usage() + provider_messages, self.session.last_measured_usage() ) except Exception: self._ctx_chars_per_token = CHARS_PER_TOKEN @@ -500,6 +505,7 @@ class AgentLoop: llm_messages, context_stats = prepare_messages_with_stats( self.session.messages, compact_threshold_chars=compact_threshold, + reasoning_replay=getattr(self.caps, "reasoning_replay", "none"), ) self._last_sent_chars = context_stats.get("sent_chars", 0) llm_start_event = { diff --git a/tests/test_context_compaction.py b/tests/test_context_compaction.py index 8bbce2a..ec9f3c8 100644 --- a/tests/test_context_compaction.py +++ b/tests/test_context_compaction.py @@ -15,6 +15,86 @@ from core.context import ( class ContextCompactionTests(unittest.TestCase): + def test_default_policy_strips_reasoning_without_mutating_history(self) -> None: + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "private reasoning", + "provider_specific_fields": {"reasoning_content": "duplicate"}, + }, + ] + + prepared, stats = prepare_messages_with_stats( + messages, compact_threshold_chars=10_000, + ) + + self.assertNotIn("reasoning_content", prepared[1]) + self.assertNotIn("provider_specific_fields", prepared[1]) + self.assertEqual(prepared[1]["content"], "answer") + self.assertEqual(stats["stripped_reasoning_messages"], 1) + self.assertGreater(stats["stripped_reasoning_chars"], 0) + self.assertEqual(messages[1]["reasoning_content"], "private reasoning") + + def test_tool_call_policy_keeps_only_tool_reasoning(self) -> None: + messages = [ + { + "role": "assistant", + "content": None, + "reasoning_content": "need a tool", + "tool_calls": [{ + "id": "tc1", "type": "function", + "function": {"name": "shell", "arguments": "{}"}, + }], + }, + {"role": "tool", "tool_call_id": "tc1", "content": "ok"}, + { + "role": "assistant", + "content": "done", + "reasoning_content": "final reasoning", + }, + ] + + prepared, stats = prepare_messages_with_stats( + messages, + reasoning_replay="tool_calls", + compact_threshold_chars=10_000, + ) + + self.assertEqual(prepared[0]["reasoning_content"], "need a tool") + self.assertNotIn("reasoning_content", prepared[2]) + self.assertEqual(stats["stripped_reasoning_messages"], 1) + + def test_tool_call_policy_promotes_nested_reasoning_content(self) -> None: + messages = [{ + "role": "assistant", + "content": None, + "provider_specific_fields": {"reasoning_content": "nested reasoning"}, + "tool_calls": [{ + "id": "tc1", "type": "function", + "function": {"name": "shell", "arguments": "{}"}, + }], + }, {"role": "tool", "tool_call_id": "tc1", "content": "ok"}] + + prepared = prepare_messages_for_llm( + messages, reasoning_replay="tool_calls", + ) + + self.assertEqual(prepared[0]["reasoning_content"], "nested reasoning") + + def test_all_policy_preserves_reasoning_verbatim(self) -> None: + message = { + "role": "assistant", + "content": "answer", + "reasoning_content": "reasoning", + "provider_specific_fields": {"signature": "opaque"}, + } + + prepared = prepare_messages_for_llm([message], reasoning_replay="all") + + self.assertEqual(prepared[0], message) + def test_preserves_system_and_recent_messages(self) -> None: messages = [ {"role": "system", "content": "rules"}, diff --git a/tests/test_context_fold.py b/tests/test_context_fold.py index 71a3766..284b5ee 100644 --- a/tests/test_context_fold.py +++ b/tests/test_context_fold.py @@ -146,6 +146,7 @@ class _FakeLLM: _FAKE_CAPS = SimpleNamespace( reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符 family="deepseek_v4", variant="flash", + reasoning_replay="tool_calls", input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0, ) @@ -226,6 +227,35 @@ class MaybeFoldTests(unittest.TestCase): self.assertEqual(sess.messages, before) # 内存零污染 self.assertTrue(any(e.get("type") == "warn" for e in events)) + def test_fold_input_strips_final_reasoning_but_keeps_tool_reasoning(self) -> None: + sess = self._session() + sess.messages[2]["reasoning_content"] = "final reasoning" + sess.messages[4] = { + "role": "assistant", + "content": None, + "reasoning_content": "tool reasoning", + "tool_calls": [{ + "id": "tc1", "type": "function", + "function": {"name": "shell", "arguments": "{}"}, + }], + } + sess.messages.insert(5, { + "role": "tool", "tool_call_id": "tc1", "name": "shell", "content": "ok", + }) + llm = _FakeLLM("## 目标\n...") + + with patch.object(cf, "persist_fold"), patch.object(cf, "record_chat_usage"): + stats = cf.maybe_fold( + sess, llm, _FAKE_CAPS, user_id=uuid4(), emit=lambda e: None, + ) + + self.assertIsNotNone(stats) + sent = llm.last_messages[:-1] + final_msg = next(m for m in sent if m.get("content", "").startswith("a000")) + tool_msg = next(m for m in sent if m.get("tool_calls")) + self.assertNotIn("reasoning_content", final_msg) + self.assertEqual(tool_msg["reasoning_content"], "tool reasoning") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_llm_kwargs.py b/tests/test_llm_kwargs.py index a0f9fd5..cbc69ed 100644 --- a/tests/test_llm_kwargs.py +++ b/tests/test_llm_kwargs.py @@ -97,6 +97,7 @@ class LLMKwargsTests(unittest.TestCase): self.assertEqual(caps.output_cny_per_mtoken, 2.0) self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02) self.assertEqual(caps.thinking_transport, "extra_body") + self.assertEqual(caps.reasoning_replay, "tool_calls") def test_other_controllable_profiles_declare_transport(self) -> None: models_dir = Path(__file__).resolve().parents[1] / "config" / "models" diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 462cd1a..12750fd 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -111,6 +111,10 @@ class StaticVendorTests(unittest.TestCase): self.assertIn("formatContextStats", src) self.assertIn("context_original_chars", src) + self.assertIn("context_stripped_reasoning_messages", src) + self.assertIn("当前请求视图压缩", src) + self.assertIn("历史已整理", src) + self.assertIn('p > 1 ? "100%+"', src) self.assertIn("cache_hit_tokens", src) def test_custom_task_name_is_optional_with_explicit_directory(self) -> None: diff --git a/tests/test_web_routes_db.py b/tests/test_web_routes_db.py index f72dd2c..d9784ca 100644 --- a/tests/test_web_routes_db.py +++ b/tests/test_web_routes_db.py @@ -126,6 +126,8 @@ class TasksCrudTests(unittest.TestCase): # 详情带上下文压力字段 d = _client.get(f"/v1/tasks/{tid}", headers=_AUTH).json() self.assertIn("context_window_chars", d) + self.assertIn("context_compacted_tool_messages", d) + self.assertIn("context_stripped_reasoning_messages", d) self.assertIn("context_folds", d) # PATCH:非法 status 400;description 生效 diff --git a/web/routers/tasks.py b/web/routers/tasks.py index bac76d0..9c4bc06 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -258,15 +258,16 @@ def register_task_routes(app, *, require_user) -> None: def get_task(task_id: str, user_id: UUID = Depends(require_user)): """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。 - 额外带上下文压力字段(仅详情端点,列表不加 —— 每 task 一条 sum 聚合,列表 - 100 行×聚合不值得):`context_window_chars`(当前窗口体量,idx>=base 的 - payload 字节和)、`context_limit_chars`(reliable_context×2.5 折算容量)、 - `context_pressure`(前者/后者,0-1+)、`context_folds`(折叠次数,= - usage_events kind='context_fold' 计数)。前端头部压缩指示环用。 + 额外带上下文压力字段(仅详情端点,列表不加):窗口消息先按当前模型的 + reasoning_replay 生成 provider-bound 视图,再计算 `context_window_chars`、 + `context_limit_chars`、`context_pressure`;同时返回当前视图会压缩的旧工具 + 消息数、剥离的 reasoning 消息数和累计 `context_folds`。前端头部指示环用。 """ - from sqlalchemy import Text as SAText, cast as sa_cast - - from core.context import CHARS_PER_TOKEN + from core.context import ( + CHARS_PER_TOKEN, + COMPACT_CONTEXT_RATIO, + prepare_messages_with_stats, + ) from core.storage.models import UsageEvent try: @@ -283,10 +284,13 @@ def register_task_routes(app, *, require_user) -> None: select(func.count()).select_from(Message).where(Message.task_id == tid) ).scalar_one() usage = usage_aggregates(s, [tid]) - window_chars = s.execute( - select(func.coalesce(func.sum(func.length(sa_cast(Message.payload, SAText))), 0)) + window_messages = [ + dict(payload) for payload in s.execute( + select(Message.payload) .where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0)) - ).scalar_one() + .order_by(Message.idx) + ).scalars().all() + ] folds = s.execute( select(func.count()).select_from(UsageEvent) .where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold") @@ -295,6 +299,14 @@ def register_task_routes(app, *, require_user) -> None: # 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None, # 前端画灰环,不 500。 limit_chars = None + window_chars = 0 + context_stats: dict[str, int] = { + "sent_chars": 0, + "saved_chars": 0, + "compacted_tool_messages": 0, + "stripped_reasoning_messages": 0, + "stripped_reasoning_chars": 0, + } try: from core.agent_builder import load_config from core.capabilities import ModelCapabilities @@ -303,9 +315,31 @@ def register_task_routes(app, *, require_user) -> None: profile = d.get("model_profile") or cfg["default_model"] caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) limit_chars = int(caps.reliable_context * CHARS_PER_TOKEN) + compact_threshold = int( + caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN + ) + _, context_stats = prepare_messages_with_stats( + window_messages, + compact_threshold_chars=compact_threshold, + reasoning_replay=caps.reasoning_replay, + ) + window_chars = context_stats["original_chars"] except Exception: - pass + # 模型档案不可用时仍返回原始 payload 的近似体量;容量保持 None,前端灰环。 + from core.context import _message_chars + window_chars = sum(_message_chars(message) for message in window_messages) d["context_window_chars"] = int(window_chars) + d["context_sent_chars"] = int(context_stats.get("sent_chars", window_chars)) + d["context_saved_chars"] = int(context_stats.get("saved_chars", 0)) + d["context_compacted_tool_messages"] = int( + context_stats.get("compacted_tool_messages", 0) + ) + d["context_stripped_reasoning_messages"] = int( + context_stats.get("stripped_reasoning_messages", 0) + ) + d["context_stripped_reasoning_chars"] = int( + context_stats.get("stripped_reasoning_chars", 0) + ) d["context_limit_chars"] = limit_chars d["context_pressure"] = ( round(int(window_chars) / limit_chars, 4) if limit_chars else None diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0fd543e..8c4b963 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2951,6 +2951,14 @@ function handleSseEvent(ev, asstCard, ctx) { state.taskMeta.context_limit_chars = cs.context_limit_chars; state.taskMeta.context_pressure = (cs.context_original_chars || 0) / cs.context_limit_chars; + state.taskMeta.context_sent_chars = cs.context_sent_chars || 0; + state.taskMeta.context_saved_chars = cs.context_saved_chars || 0; + state.taskMeta.context_compacted_tool_messages = + cs.context_compacted_tool_messages || 0; + state.taskMeta.context_stripped_reasoning_messages = + cs.context_stripped_reasoning_messages || 0; + state.taskMeta.context_stripped_reasoning_chars = + cs.context_stripped_reasoning_chars || 0; refreshCtxRing(); } // 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示 diff --git a/web/static/js/format.js b/web/static/js/format.js index c03c121..ca972f7 100644 --- a/web/static/js/format.js +++ b/web/static/js/format.js @@ -79,12 +79,18 @@ export function ctxRingTitle(t) { if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要"); else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩"); else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送"); + const stripped = t.context_stripped_reasoning_messages || 0; + const compacted = t.context_compacted_tool_messages || 0; + if (stripped > 0) { + lines.push(`模型输入已剥离 ${stripped} 条历史思考(页面历史仍保留)`); + } + if (compacted > 0) { + lines.push(`当前请求视图压缩 ${compacted} 条旧工具输出`); + } } else { lines.push("窗口占用未知(模型容量档案不可用)"); } - if (t.context_folds > 0) { - lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`); - } + lines.push(`历史已整理 ${t.context_folds || 0} 次(早期消息折叠为摘要,原文在历史里可查)`); return lines.join("\n"); } @@ -97,7 +103,7 @@ export function ctxRingHtml(t) { : p >= 0.85 ? "#c0392b" : p >= 0.5 ? "#d99a1b" : "#3a9d5d"; - const label = known ? `${Math.round(p * 100)}%` : "–"; + const label = known ? (p > 1 ? "100%+" : `${Math.round(p * 100)}%`) : "–"; const folds = t.context_folds || 0; const foldBadge = folds > 0 ? `已整理×${folds}` : "";