From 66a515aa3d1850f34672fc726a7975410f6d13e7 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 9 Jul 2026 12:39:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(context):=20=C2=A78.8=20Phase=202=20?= =?UTF-8?q?=E9=95=BF=E4=BC=9A=E8=AF=9D=E4=B8=AD=E6=AE=B5=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E6=91=98=E8=A6=81=20+=20=E5=A4=B4=E9=83=A8=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E6=8C=87=E7=A4=BA=E7=8E=AF(bump=200.54.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 折叠(0.54.0):run 起点窗口体量达 reliable_context×85% → core/context_fold.py 把中段折叠为固定模板结构化摘要(路径/ID/数值原文保留),写 tasks.context_summary (migration 0021)+ 推进 context_base_idx;Session.load 注入仅内存「前情摘要」 (_n_head/_base_idx 维护 idx 映射);摘要调用复用会话 prepare 后前缀 + 末尾追加 指令,与上一轮 chat 缓存字节一致近全程 hit;失败零阻塞(warn+跳过);「新话题」 硬重置 / web 清空对话顺带清摘要,gap 软重置保留 - 前端(0.54.0):新 SSE context_fold 事件(start 状态行提示 / done 流内分隔条); 补 warn 事件渲染 —— 此前 loop 熔断/重复拦截提示在 web 端被静默丢弃 - 压缩指示环(0.54.1):头部金额撤下明面(挪 hover tooltip),换 SVG donut (绿<50% 未压缩 / 琥珀 50-85% 压缩区 / 红≥85% 达折叠阈值)+ 已整理×N; GET /v1/tasks/{id} 详情新增 context_window_chars/limit_chars/pressure/folds (additive,列表端点不加);折叠计费 kind="context_fold"(record_chat_usage 加 kind 参数,折叠次数 = count 该 kind,零 migration);llm_start 事件补 context_limit_chars,run 中实时刷环 - DESIGN:§8.11 新增「最小子循环 delegate」预留(上下文隔离而非多 agent 编排, 按 diag 数据触发,无信号不实施);§1/§6 "不做 subagent" 收窄为编排型; §8.8 Phase 2 标 ✅ 沉淀六条取舍;§7.2 SSE 清单补 warn/context_fold - tests/test_context_fold.py 12 项(切点/映射/apply_fold/maybe_fold 打桩), 全量 198 过;本地 DB 已升 0021 Co-Authored-By: Claude Fable 5 --- DESIGN.md | 24 ++- PROGRESS.md | 5 +- core/__init__.py | 2 +- core/context.py | 6 + core/context_fold.py | 196 +++++++++++++++++ core/loop.py | 31 ++- core/session.py | 59 ++++- core/storage/models.py | 4 + core/storage/usage.py | 5 +- core/wechat/service.py | 11 +- ...20260709_1000_0021_task_context_summary.py | 33 +++ tests/test_context_fold.py | 204 ++++++++++++++++++ web/app.py | 48 ++++- web/static/js/chat.js | 66 +++++- web/static/js/format.js | 64 +++++- 15 files changed, 721 insertions(+), 37 deletions(-) create mode 100644 core/context_fold.py create mode 100644 db/migrations/versions/20260709_1000_0021_task_context_summary.py create mode 100644 tests/test_context_fold.py diff --git a/DESIGN.md b/DESIGN.md index 757c5c0..76e82cb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,7 +8,7 @@ ## 1. 边界 **做**:PPT / 申报书 / 编码(读写文件 + shell + 迭代验证)。 -**不做**:子 agent / 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。 +**不做**:子 agent(编排型;上下文隔离的最小子循环另见 §8.11)/ 自定义 RAG / 锁定 Anthropic / Eval Suite(dogfooding 替代)。多用户 / Web 归 §7。 **关键约束**:模型自由(LiteLLM,默认 DeepSeek V4);任务持久化(任意时刻关机可恢复);演化性(模型升级不大改架构);**形态兼容**——本地与 SaaS 共享同一份 core / PG / web `/v1` API,无 CLI REPL 分叉(§7.9)。 @@ -113,7 +113,7 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM | long context 退化 | probe 探测可靠 ceiling | | 本地 PG 离线 | docker compose 起本地 PG / 连远端 | -**Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent**:状态管理爆炸,单 agent + skill 覆盖 95%。**不做 Eval Suite**:单用户 dogfooding 信号更强。 +**Hybrid 而非纯 CodeAgent**:V4 JSON tool call 已稳,sandbox 成本按需付。**不做 subagent(编排型)**:状态管理爆炸,单 agent + skill 覆盖 95%;上下文隔离的最小子循环是另一个问题,见 §8.11(有触发条件,无信号不实施)。**不做 Eval Suite**:单用户 dogfooding 信号更强。 --- @@ -150,7 +150,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/ Export GET /v1/tasks/{id}/export(docx) ``` -**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。 +**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。 **版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。 ### 7.3 认证 @@ -295,14 +295,14 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) - **渠道 B 企业微信**:出站推送先行("和邮箱似的"),公测需求明确后补入站——**与 ClawBot 本质不同**:回调 webhook 而非长轮询(无后台 task,只加 HTTP 端点 + `wecom_crypto.py` 验签解密);agent >5s 超被动窗口 → 回复走 message/send 主动推回。绑定两路:手填 userid(推送是出站直连**不需要域名**)+ OAuth 扫码(需 HTTPS 可信域名,用 wwlogin 扫码端点非网页授权端点)。 - **不选**:wechaty(违规);富排版卡片(个微能力存疑,统一纯文本+文件直推);bot_token 不落库(它是长期凭据必须持久化,安全靠加密列)。 -### 8.8 channel 长会话上下文治理(Phase 1 ✅ / 2-3 design) +### 8.8 channel 长会话上下文治理(Phase 1-2 ✅ / 3 design) **根因**:IM 常驻 task 只增不减(web 任务"做完即止"有天然边界,IM 没有),全量历史越用越贵终撞 context window;§8.2 压缩只摘 tool 正文挡不住跨时段累积。业界(OpenClaw/Hermes/Claude Code)都是"阈值摘要+头尾保护",但都是单次 session,不解"IM 用三个月"——IM 独有的**会话分段**最高杠杆且零信息损失,自补。 **心智:边界而非删除**——一条消息都不删,只移动喂给模型的窗口起点;全历史留 DB,web 照旧翻完整记录。 - **Phase 1(✅):`context_base_idx` 软重置**。`Session.load` 只装 `idx>=base`;自动 gap(默 6h,base=最后一条 user 消息——**不是失忆墙**,留上一轮做续聊锚点)+ 手动「新话题」硬重置(base=总数)。**关键不变量**:append 续号取 DB 真实总条数而非加载条数,否则撞 unique 约束。**不选**"每次 gap 开新 task"(堆文件夹+task 卡片)、"boundary 标记消息"(混进消息流要处理 tool 配对);列是纯元数据零侵入。 -- **Phase 2(design):阈值结构化摘要**(补 Hermes 阶段③):中段轮压成固定模板(目标/约束/进展/待办),增量更新、保留 path/ID/数值原文(mem0 实测摘要会静默丢精确值);双层门槛 50%+85%。分段砍跨话题累积、摘要兜单段超长,正交。 +- **Phase 2(✅ 2026-07-09):阈值结构化摘要**(补 Hermes 阶段③,`core/context_fold.py`):run 起点窗口体量达 `reliable_context×85%` → 中段折叠成固定模板摘要(目标/约束决定/进展/待办 + path/ID/数值**原文保留**,mem0 实测自由摘要会静默丢精确值),存 `tasks.context_summary`(0021)+ 推进 `context_base_idx`,`Session.load` 注入仅内存的「前情摘要」user 消息。双层门槛 50%(压缩)+85%(折叠)正交:分段砍跨话题累积、摘要兜单段超长。关键取舍:① **run 起点触发而非轮间**(轮间要处理 tool 配对切割 + 已加载窗口一致性,回合制下 run 起点是自然缝隙,代价是命中那一回合首 token 慢几秒、每分段一两次);② **摘要存 tasks 列不入消息流**(boundary 消息会混进 tool 配对处理,Phase 1 已拒过;列是纯元数据零侵入);③ **前缀缓存友好**:摘要调用复用会话 prepare 后的消息前缀 + 末尾追加指令 → 与上一轮 chat 缓存字节一致近全程 hit;增量更新 = 旧摘要本在被折前缀里,指令要求合并,不重读全史;折叠后窗口字节稳定至下次折叠,且体量回落 50% 门槛以下、压缩关闭,前缀比折叠前更稳;④ **失败零阻塞**(warn + 跳过,85% 距硬上限有垫);⑤ 切点必落 user 消息(不劈 tool 配对,窗口恒以 user 开头,与 Phase 1 锚点同语义);⑥ 「新话题」硬重置/清空对话清摘要,gap 软重置保留;对全部 task 生效(机制通用,web 短任务不达阈值零影响)。 - **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。 ### 8.9 产物机检门 + 提示层禁令纪律(✅ 2026-07-06) @@ -313,6 +313,20 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) **推翻 0.36.0 的渲图验收闭环**(svg_preview 渲 PNG → look_at_image 逐页过目 → accept_pages 标 pass → 导出 gate 校验 sha1)。三条根因:①**成本**——逐页 vision 26-42s/页、每 deck 8-25 次调用,烧 token 大头;②**环境脆弱**——沙箱 chromium "找到了但渲染崩"没有回退路径,硬门+死路逼模型即兴发挥(pip install cairosvg、手写渲染循环,正是 8.9 要防的行为);③**验收环节的"看"本身无法机检**——gate 只能强制"渲过",看没看/看得准不准全凭模型自觉,0.36.0 就写明了这个边界。**替代**:质检器 check 13/14 的精确几何检测(越界/压字/错位/网格漂移/图表退化)已覆盖"正确性"面,且在导出边界自动复跑;**主动放弃美学验收**(配色观感/页面空挤,单调门除外)——底线正确性机检可保,观感交给用户反馈迭代。svg_preview 保留为手动工具(chromium 失败自动回退 cairosvg)但 **SKILL 提示面零提及**——不给渲染入口模型就不会主动渲(同 8.9"禁令不带配方"逻辑,正面只写"导出唯一入口 svg_to_pptx");accept_pages/acceptance.json/--allow-unreviewed 整套删除。**回退信号**:若"几何全对但观感翻车"成为用户改稿主因,再考虑便宜的整本拼图单次 vision 抽检,而非逐页。 +### 8.11 最小子循环 delegate:上下文隔离而非多 agent 编排(design,2026-07-08,按诊断数据触发) + +**根因**:检索/扫文类工作(文献 brief、document_search、批量读文件)的形态是"中间数据量大、最终只要结论"——在主循环里跑,中间数据必然流经主上下文,污染 + 膨胀是**架构性的**。已踩实例:38 篇 abstract 反复 dump 烧 2.5M token、`document_search` 同参调 122 次不收敛。现有缓解(`_RepeatGuard` 熔断、§8.2 context 压缩、brief skill 的 context 纪律)全是**行为约束**——劝模型别乱来,不改变"中间数据必须过主上下文"这个结构;同 8.9 的教训,提示层纪律挡不住结构性问题。 + +**决策**:§6"不做 subagent"针对的是**编排型多 agent**(并行、状态共享、agent 间通信、任务分解),该结论不变。本条预留的是**一个工具**:`delegate(instruction) -> str 摘要`——复用现成 `AgentLoop` 起一个全新空上下文的子循环,只注只读工具(检索/读文件类白名单),硬轮数上限(~20),跑完只把文字摘要返回主循环。主循环视角就是一次普通 tool call,零状态共享、零并行、零 agent 间协议;实现量约一个文件。 + +**触发条件(无信号不实施)**:RepeatGuard + brief 纪律上线后,`scripts/diag_*.py` 数据仍显示检索型 task 是烧 token 大户 / 检索中间数据占上下文大头。数据收敛则本条永久搁置。 + +**不选**: +- 完整多 agent 编排:状态管理爆炸(§6),且 zcbot 无真实并行需求——用户没有"同时审 3 篇"诉求,职责隔离已由 skill 体系覆盖。 +- 继续加码 skill 纪律/熔断规则:行为约束对抗不了架构性上下文污染,规则越堆越像 §7.8 的黑名单(fundamentally broken)。 + +**实施时对账清单**:①子循环禁注 `delegate` 自身(防自我繁殖,同 8.5 定时 run 禁 schedule_create);②工具集只读白名单,不给 shell/fs 写/run_python;③计费入 usage_events 归属主 task(kind 区分);④主 run 的 cancel_check 传导进子循环;⑤子循环事件不直播 SSE(或只 emit 一条聚合摘要事件),防前端刷屏;⑥子循环产出若超长仍走"落文件留路径"纪律(§8.2 质量边界)。 + --- ## 附录:DeepSeek V4 关键事实(2026-04-24) diff --git a/PROGRESS.md b/PROGRESS.md index 0218994..4ab4bcc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-08(个人微信 ClawBot 语音接入 + 企微语音路径补日志,bump 0.52.0) +最后更新:2026-07-09(头部压缩指示环替换金额显示,bump 0.54.1) --- @@ -13,7 +13,7 @@ | 1-3 | 骨架 + Skill + run_python | ✅ | 多 skill(coding/proposal/ppt/research/documents/imagegen/videogen/review/patent);CoreCoder 唯一匹配 edit;敏感 env 过滤 | | 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | -| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛) | +| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩 ✅(加压力门槛);长会话中段折叠摘要 ✅(§8.8 Phase 2) | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工 ✅;D `/v1` JSON API ✅;D' 过渡 auth + dev SPA ✅;单活 run 锁 + cancel ✅;0004 schema 瘦身 ✅;入口归位 ✅;真 OIDC 待;**C Step 1-3 + 3d ✅(Executor + Docker 池 + DockerExecutor + fs 工具进容器)+ Step 5 部署前置对账 ✅ + 容器资源 yaml + 应用层磁盘配额 ✅ + dogfood 网络放开 + 容器内 pip/npm 源持久化 ✅**;**Step 4 完整 egress proxy + Step 3b PGID kill 协议延后到外部用户开放前**(还需 egress proxy + xfs project quota OS 层硬化,§7.5 落地清单 #2 #4)。 | @@ -23,6 +23,7 @@ ### 2026-07 +- **07-09 / 0.54.1**:**对话头部压缩指示环**(用户提出:金额撤下明面,换编辑器式上下文环)——头部用量区改为 `N tok + 环形指示器`:环 = 窗口体量 / 模型容量(`reliable_context×2.5` chars),色阶映射三段机制(绿 <50% 未压缩 / 琥珀 50-85% 压缩区 / 红 ≥85% 达折叠阈值),折叠过附 `已整理×N`;金额/缓存命中率挪 hover tooltip(taskUsageTooltip 本就有,信息不丢)。数据链:① `GET /v1/tasks/{id}` 详情端点(列表不加,免 100 行×聚合)新增 `context_window_chars/limit_chars/pressure/folds` 四个 additive 字段(窗口体量 = `sum(length(payload::text)) where idx>=base` 单聚合;容量按 task 模型档案折算,读不出置 None 前端画灰环);② 折叠计费 kind `"chat"→"context_fold"`(`record_chat_usage` 加 kind 参数,prompt_optimize 同范式;折叠次数 = count 该 kind,零 migration,admin 按 kind 对账折叠成本)——0.54.0 未部署无存量,改名零成本;③ `llm_start` 事件补 `context_limit_chars`,run 中每轮实时刷环,`context_fold` done 事件折叠计数 +1 + 体量骤降环回绿;④ PATCH 模型/清空对话两处 `taskMeta` 整体覆盖点改 merge,保住环数据(清空顺带归零)。`format.js` 纯 SVG donut(14px,stroke-dasharray)无依赖。**§8.8 Phase 2 长会话中段折叠摘要**(补齐 IM 常驻会话"单段超长"缺口;Phase 1 gap 分段只砍跨话题累积)——run 起点窗口体量达 `reliable_context×85%`(chars×2.5 折算,与 50% 压缩门槛同源常量,挪 `core/context.py`)→ 新模块 `core/context_fold.py` 把窗口中段(保留尾部 ~30%,切点必落 user 消息不劈 tool 配对)折叠为固定模板结构化摘要(目标/约束决定/进展/待办/关键路径·ID·数值原文保留),写 `tasks.context_summary`(migration 0021)+ 推进 `context_base_idx`,`Session.load` 在 system 后注入仅内存的「[前情摘要]」消息(`_n_head/_base_idx` 元数据维护 idx 映射不变量)。**前缀缓存友好**:摘要调用复用会话自身 prepare 后的消息前缀 + 末尾追加指令(与上一轮 chat 缓存字节一致,近全程 cache hit;切点跨列表用"第 k 条 user"对齐);增量更新 = 旧摘要本在被折前缀里,指令要求合并。失败零阻塞(warn + 跳过);计费入 usage_events(message_id=NULL);「新话题」硬重置/web 清空对话顺带清摘要,gap 软重置保留。前端:新 SSE `context_fold` 事件(start 状态行提示 / done 流内分隔条),顺手补 `warn` 事件渲染(此前 loop 熔断/重复拦截提示在 web 端被静默丢弃)。单测 `tests/test_context_fold.py` 12 项(切点/映射/apply_fold/maybe_fold 打桩),全量 198 过。 - **07-08 / 0.53.1**:**新增 rebuttal skill(审稿意见回复 / 修回)+ paper·review 增强**——调研 GitHub nature skill 生态(Yuan1z0825/nature-skills 26.9k★ Apache-2.0、Boom5426/Nature-Paper-Skills MIT)后移植三块:① 新 skill `rebuttal`(填投稿后环节空白):决定信 / 审稿意见 → 逐点回复信 + 稿件修改清单 + 修回 cover letter,拆条分诊(E.x/Rn.m 编号 → 8 类 × 4 档严重度 × 12 动作标签 tracker)→ 逐条策略确认 → **先改稿后写信** → QA 渲染(复用 --profile paper,中英双语);零编造铁律(不虚构实验/行号/图号/"已改"声明,含糊腹稿一律 AUTHOR_INPUT_NEEDED 追问,中文备注九种含糊转换表)、语气纪律(不同意五步窄口径、拒实验只用范围边界不用时间经费)、疑难案例库(两审稿人打架/统计大修/合规 blocking/appeal 识别)、readiness 四档诚实标注;② paper 补 `figure_discipline.md`(一图一论断/panel 角色/锚 panel/主图 vs 补充材料/图例四规则,阶段三必读)+ `submission_audit.md`(六步通读审计抓 quality_check 机检不到的跨章漂移 + 数据可用性声明模式含企业受限场景,阶段六必读);③ review 补 `sci_polish.md` 英文 SCI 润色子模式(四层递进诊断:全文策略→章节职责→段落逻辑→句子润色,章节职责错了不做句子润色;claim/evidence/boundary;中译英先抽命题不逐句翻)。未装:nature 系文献检索/下载类(自有 research/documents/brief 后端对不上)、paper2ppt/paper-to-patent/proposal-writer(已有国内定制版更强)。 - **07-08 / 0.53.0**:**录音文件转写工具 `transcribe_audio`**(用户上传录音要"转文字/总结"的需求):新 `core/asr_lfasr.py`(讯飞录音文件转写标准版,raasr v2:signa 签名 + upload/getResult 异步订单 + lattice 解析 + 说话人分离格式化)+ `tools/transcribe_audio.py`(look_at_image 同形态 host-side tool,路径复用 image_ref.resolve_in_root)。选 LFASR 弃 IAT 分片方案:单文件 5h/500MB、服务端解码常见格式(本地不需要 ffmpeg)、roleType=1 自带说话人分离(会议场景直接可用);全文落 `<音频名>.transcript.txt` 只回预览+路径,不撑爆 context。凭据 `XFYUN_APPID`(共用)+ 新 env `XFYUN_LFASR_SECRET_KEY`(独立 SecretKey,控制台「录音文件转写标准版」页),没配不挂工具;轮询期响应停止按钮。实测:TTS 合成 12.6s 中文语音 13s 出稿逐字全对;orderInfo.originalDuration 是上传参数回显、realDuration 才是真实时长(踩坑记入代码注释)。smoke `scripts/smoke_transcribe_audio.py`(离线 12 项)+ 诊断 `scripts/diag_lfasr.py`。 - **07-08 / 0.52.6**:企微语音落地收尾——"文字/语音全无反应"根因定位:**回调注册在企微平台侧失效**(服务器/代码/env 全正常),后台重新保存「接收消息」API 配置即恢复;RUN 故障兜底补该行(判据:`[wecom] inbound` 行没有=平台没投递)。至此两渠道语音全链路用户实测跑通:个人微信(SILK→pilk)+ 企业微信(AMR→ffmpeg)→ 讯飞 IAT → 回显「已识别」→ 进对话。 diff --git a/core/__init__.py b/core/__init__.py index dcfc46b..402272c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.53.1" +__version__ = "0.54.1" diff --git a/core/context.py b/core/context.py index ae6af51..5abd9f7 100644 --- a/core/context.py +++ b/core/context.py @@ -14,6 +14,12 @@ from typing import Any, List import json import re +# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token)与压缩触发比例。 +# loop(50% 压缩门槛)与 context_fold(85% 折叠门槛)共用,单一事实源;压缩/折叠是 +# 成本/安全优化、非正确性关键,估算粗糙无妨。 +CHARS_PER_TOKEN = 2.5 +COMPACT_CONTEXT_RATIO = 0.5 + def _compact_old_tool_content(content: str, max_chars: int) -> str: if len(content) <= max_chars: diff --git a/core/context_fold.py b/core/context_fold.py new file mode 100644 index 0000000..a7979ad --- /dev/null +++ b/core/context_fold.py @@ -0,0 +1,196 @@ +"""§8.8 Phase 2:阈值结构化摘要 —— 长会话窗口中段折叠。 + +Phase 1(context_base_idx 软重置)解决"跨话题累积";单个分段内持续对话,窗口仍只增 +不减 —— 50% 门槛的旧 tool 压缩(core/context.py)只摘工具正文,user/assistant 对话 +本体一字不少,最终仍会撞 reliable_context。本模块在 run 起点检测窗口体量,达 +reliable_context × FOLD_TRIGGER_RATIO 时把窗口中段(保留尾部约 FOLD_TAIL_RATIO) +折叠为一份固定模板的结构化摘要。 + +设计要点(取舍详 DESIGN §8.8): +- **边界而非删除**:原文全量留 messages 表,只写 tasks.context_summary + 推进 + context_base_idx;web 端照旧翻完整历史。 +- **增量更新**:旧摘要作为注入消息本就在被折叠的前缀里,摘要指令要求合并 —— + 新摘要 = f(旧摘要, 新折叠段),不重读全史。 +- **前缀缓存友好**:摘要调用复用会话自身的消息前缀(prepare 同款压缩后取 [:切点]), + 末尾追加一条摘要指令 user 消息 —— 前缀与上一轮 chat 的缓存字节一致,几乎全程 + cache hit;折叠后的窗口(system + 摘要 + 尾段)在下次折叠前字节稳定,缓存恢复全暖。 +- **精确值原文保留**:模板硬性要求路径/ID/数值不得改写(mem0 实测自由摘要会静默丢 + 精确值),守 §8.2 质量边界(确认过的需求/结论不删)。 +- **失败零阻塞**:摘要调用失败只 warn + 跳过本次折叠,run 照常跑(85% 距硬上限有垫)。 +""" +from __future__ import annotations + +from typing import Any, Callable, List, Optional +from uuid import UUID + +from sqlalchemy import update + +from .context import ( + CHARS_PER_TOKEN, + COMPACT_CONTEXT_RATIO, + _message_chars, + prepare_messages_with_stats, +) +from .storage import session_scope +from .storage.models import Task +from .storage.usage import record_chat_usage + +# 窗口体量达 reliable_context 的此比例 → 触发折叠(50% 压缩、85% 折叠双层门槛)。 +FOLD_TRIGGER_RATIO = 0.85 +# 折叠后保留的尾部体量占比(近期对话原文,续聊手感;其余折进摘要)。 +FOLD_TAIL_RATIO = 0.30 +# 可折叠段少于这么多条消息就不折 —— 摘要调用本身有成本,段太短折了省不了几个字。 +MIN_FOLD_MESSAGES = 8 + +SUMMARY_NOTE_HEADER = "[前情摘要]" + +# 摘要指令:作为最后一条 user 消息追加在会话前缀之后(前缀缓存复用,见模块注释)。 +FOLD_INSTRUCTION = ( + "请暂停手头任务,现在只做一件事:把以上全部对话内容压缩成一份结构化摘要," + "它将替代这些消息作为后续对话的上下文。若开头已有「[前情摘要]」,把它合并进来。\n" + "严格按以下模板输出,除模板内容外不要输出任何别的文字:\n" + "## 目标\n## 已确认的约束与决定\n## 进展\n## 待办\n## 关键路径 / ID / 数值\n" + "硬性要求:文件路径、URL、ID、数字、代码符号、命令等精确值必须原文保留," + "不得改写、四舍五入或省略;用户明确确认过的需求与结论必须保留;" + "不确定的内容标注[待确认]。" +) + + +def build_summary_note(summary: str) -> dict: + """把摘要包成注入消息(user 角色,仅内存不落库;Session.load 与折叠现场共用)。""" + return { + "role": "user", + "content": ( + f"{SUMMARY_NOTE_HEADER} 以下是本对话更早内容的结构化摘要" + "(原始消息已归档,不在当前上下文中);请把它当作已发生的事实," + "直接继续对话,不必复述:\n\n" + summary + ), + } + + +def find_cutoff(messages: List[dict], head_len: int) -> Optional[int]: + """选折叠切点:返回内存 index(切点本身保留在窗口里),不可折返回 None。 + + 从尾部向前累计体量,第一条使尾部 ≥ FOLD_TAIL_RATIO 的 **user 消息**即切点 —— + 切在 user 消息上天然不劈开 assistant tool_calls / tool 配对,与 Phase 1 软重置 + 的锚点语义一致(窗口永远以 user 消息开头)。user 消息稀疏时尾部占比可能超过 + FOLD_TAIL_RATIO,保守方向,可接受。 + """ + n = len(messages) + if n <= head_len: + return None + total = sum(_message_chars(m) for m in messages[head_len:]) + tail_target = total * FOLD_TAIL_RATIO + acc = 0 + cutoff: Optional[int] = None + for i in range(n - 1, head_len - 1, -1): + acc += _message_chars(messages[i]) + if acc >= tail_target and messages[i].get("role") == "user": + cutoff = i + break + if cutoff is None or cutoff - head_len < MIN_FOLD_MESSAGES: + return None + return cutoff + + +def _user_ordinal(messages: List[dict], idx: int) -> int: + """messages[idx] 是从头数第几条 user 消息(1-based)。""" + return sum(1 for m in messages[: idx + 1] if m.get("role") == "user") + +def _kth_user_index(messages: List[dict], k: int) -> Optional[int]: + """从头数第 k 条(1-based)user 消息的 index;不存在返回 None。""" + seen = 0 + for i, m in enumerate(messages): + if m.get("role") == "user": + seen += 1 + if seen == k: + return i + return None + + +def persist_fold(task_id: UUID, new_base: int, summary: str) -> None: + """折叠结果落库:摘要 + 窗口起点,一个事务。先于内存变更执行 —— DB 写失败时 + 内存未动,本次折叠白跑一次摘要调用但状态零污染,下次 run 重试。""" + with session_scope() as s: + s.execute( + update(Task) + .where(Task.task_id == task_id) + .values(context_base_idx=new_base, context_summary=summary) + ) + + +def maybe_fold( + session: Any, + llm: Any, + caps: Any, + *, + user_id: UUID, + emit: Callable[[dict], None], +) -> Optional[dict]: + """run 起点调用:体量达阈值则折叠,返回统计 dict;未触发/不可折返回 None。 + + session 鸭子类型,需要:messages / task_id / context_head_len / context_base / + apply_fold(cutoff, summary)。异常向上抛,由 loop 包 try 兜(失败零阻塞纪律)。 + """ + msgs = session.messages + before_chars = sum(_message_chars(m) for m in msgs) + threshold = int(caps.reliable_context * FOLD_TRIGGER_RATIO * CHARS_PER_TOKEN) + if before_chars < threshold: + return None + head_len = session.context_head_len + cutoff = find_cutoff(msgs, head_len) + if cutoff is None: + return None + folded_count = cutoff - head_len + emit({"type": "context_fold", "phase": "start", "folding_messages": folded_count}) + + # 复用 loop 同款 prepare(50% 门槛旧 tool 压缩)再取前缀 —— 字节与上一轮 chat 的 + # 缓存前缀一致,摘要调用吃到 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) + p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff)) + if p_cut is None: # 理论不可达,防御 + return None + fold_input = prepared[:p_cut] + [{"role": "user", "content": FOLD_INSTRUCTION}] + + response = llm.chat(messages=fold_input) + choices = getattr(response, "choices", None) or [] + summary = ((choices[0].message.content if choices else "") or "").strip() + if not summary: + emit({"type": "warn", "msg": "context fold: 摘要调用返回空,本轮跳过折叠"}) + return None + + usage = getattr(response, "usage", None) + try: + record_chat_usage( + task_id=session.task_id, + user_id=user_id, + message_id=None, # 摘要不入 messages,event 挂 task 维度 + model_profile=f"{caps.family}.{caps.variant}", + prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0, + completion_tokens=getattr(usage, "completion_tokens", 0) or 0, + input_cny_per_mtoken=caps.input_cny_per_mtoken, + output_cny_per_mtoken=caps.output_cny_per_mtoken, + response=response, + # 独立 kind:按 kind 对账"折叠烧多少钱",且折叠次数 = count(该 kind) + # (任务详情 context_folds 字段的数据源,免加计数列) + kind="context_fold", + ) + except Exception: + pass # 记账失败不阻塞折叠(与 loop 记账同纪律) + + 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) + stats = { + "type": "context_fold", + "phase": "done", + "folded_messages": folded_count, + "before_chars": before_chars, + "after_chars": after_chars, + "new_base": new_base, + } + emit(stats) + return stats diff --git a/core/loop.py b/core/loop.py index 1222671..e14f95e 100644 --- a/core/loop.py +++ b/core/loop.py @@ -20,7 +20,8 @@ from uuid import UUID import litellm from .capabilities import ModelCapabilities -from .context import prepare_messages_with_stats +from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_with_stats +from .context_fold import maybe_fold from .executor import ExecCtx, Executor from .llm import LLM from .session import Session @@ -274,6 +275,7 @@ class AgentLoop: }) def run(self, user_message: str) -> str: + self._maybe_fold_context() self.session.append({"role": "user", "content": user_message}) for _ in range(self.max_iterations): @@ -400,10 +402,30 @@ class AgentLoop: # 上下文压缩门槛:历史体量未到 reliable_context 的此比例前不压缩 —— 短任务不丢旧工具细节, # 且 prompt 前缀逐轮字节一致、DeepSeek 前缀缓存全程命中。50% 留足上下文安全垫。 - _COMPACT_CONTEXT_RATIO = 0.5 + # 常量本体在 core/context.py(与 context_fold 的 85% 折叠门槛共用折算,单一事实源)。 + _COMPACT_CONTEXT_RATIO = COMPACT_CONTEXT_RATIO # chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token);压缩是成本/安全优化、 # 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。 - _CHARS_PER_TOKEN = 2.5 + _CHARS_PER_TOKEN = CHARS_PER_TOKEN + + def _maybe_fold_context(self) -> None: + """§8.8 Phase 2:run 起点检查窗口体量,达 reliable_context×85% 则把窗口中段 + 折叠成结构化摘要(core/context_fold.py,SSE 事件 context_fold)。 + + 放 run 起点而非轮间:轮间折叠要处理 tool 配对切割与已加载窗口的一致性,复杂 + 一档;对话是回合制,run 起点是自然缝隙,代价只是命中阈值那一回合首 token 慢 + 几秒(每分段一两次)。失败只 warn + 跳过,绝不阻塞本次 run(85% 距硬上限有垫)。 + """ + try: + maybe_fold( + self.session, self.llm, self.caps, + user_id=self.user_id, emit=self._emit, + ) + except Exception as e: + self._emit({ + "type": "warn", + "msg": f"context fold failed: {type(e).__name__}: {e};本轮跳过折叠", + }) def _stream_llm(self) -> Tuple[Optional[Any], bool]: """拉一轮 LLM 并保证返回的 tool_call arguments 可解析。 @@ -429,6 +451,9 @@ class AgentLoop: self._emit({ "type": "llm_start", **{f"context_{k}": v for k, v in context_stats.items()}, + # 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比; + # 50% 进压缩区、85% 触发折叠(context_fold)。 + "context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN), }) for attempt in range(self._MAX_MALFORMED_RETRIES + 1): use_nonstream = attempt == self._MAX_MALFORMED_RETRIES diff --git a/core/session.py b/core/session.py index 4e6d458..f8410e8 100644 --- a/core/session.py +++ b/core/session.py @@ -66,8 +66,14 @@ class Session: self.messages: List[dict] = [] self.meta: Dict[str, Any] = dict(meta or {}) self._db_idx: int = 0 # 下一条要写 DB 的 idx + # 上下文窗口元数据(0019/0021):_base_idx = 窗口起点的 DB idx;_n_head = 内存 + # 头部"不在 DB 里"的消息条数(system + 可选注入的前情摘要)。 + # 映射不变量:messages[i] 的 DB idx = _base_idx + (i - _n_head),i >= _n_head。 + self._base_idx: int = 0 + self._n_head: int = 0 if system_prompt: self.messages.append({"role": "system", "content": system_prompt}) + self._n_head = 1 def append(self, msg: Any) -> Optional[UUID]: """追加消息;非 system 落 DB,system 仅内存。返回新落库行的 message_id。 @@ -95,15 +101,48 @@ class Session: self._db_idx += 1 return msg_id + @property + def context_head_len(self) -> int: + """内存头部不落 DB 的消息条数(system + 可选前情摘要),窗口 idx 映射用。""" + return self._n_head + + @property + def context_base(self) -> int: + """当前窗口起点的 DB idx(tasks.context_base_idx 的内存镜像)。""" + return self._base_idx + + def apply_fold(self, cutoff: int, summary: str) -> None: + """折叠内存窗口(§8.8 Phase 2):裁掉 head 与 cutoff 之间的消息,注入新前情摘要。 + + 只动内存;DB 持久化(tasks.context_summary + context_base_idx)由 + context_fold.persist_fold 先行完成。cutoff 为内存 index,必须指向 user 消息 + (find_cutoff 保证),折叠后窗口 = [system?] + [前情摘要] + messages[cutoff:]。 + _db_idx 不变(总条数没变,append 续号不受影响)。 + """ + from .context_fold import build_summary_note + + new_base = self._base_idx + (cutoff - self._n_head) + head = ( + [self.messages[0]] + if self._n_head and self.messages and self.messages[0].get("role") == "system" + else [] + ) + self.messages = head + [build_summary_note(summary)] + self.messages[cutoff:] + self._n_head = len(head) + 1 + self._base_idx = new_base + def reset(self, keep_system: bool = True) -> None: """清空消息。keep_system 仅影响内存(system 本来就不在 DB)。""" if keep_system and self.messages and self.messages[0].get("role") == "system": self.messages = [self.messages[0]] + self._n_head = 1 else: self.messages = [] + self._n_head = 0 with session_scope() as s: s.execute(delete(Message).where(Message.task_id == self.task_id)) self._db_idx = 0 + self._base_idx = 0 @classmethod def load( @@ -124,9 +163,18 @@ class Session: """ sess = cls(task_id=task_id, system_prompt=system_prompt, meta=meta) with session_scope() as s: - base = s.execute( - select(Task.context_base_idx).where(Task.task_id == task_id) - ).scalar_one_or_none() or 0 + task_row = s.execute( + select(Task.context_base_idx, Task.context_summary) + .where(Task.task_id == task_id) + ).first() + base = (task_row.context_base_idx if task_row else 0) or 0 + summary = (task_row.context_summary if task_row else "") or "" + # 前情摘要(0021,§8.8 Phase 2):仅内存注入在 system 之后,不占 DB idx。 + if summary: + from .context_fold import build_summary_note + sess.messages.append(build_summary_note(summary)) + sess._n_head += 1 + sess._base_idx = base rows = s.execute( select(Message) .where(Message.task_id == task_id, Message.idx >= base) @@ -159,5 +207,6 @@ class Session: return cnt is not None def n_user_msgs(self) -> int: - """内存里 user 消息数,用于 _cleanup_if_empty 守门(避免回 DB)。""" - return sum(1 for m in self.messages if m.get("role") == "user") + """内存里 user 消息数,用于 _cleanup_if_empty 守门(避免回 DB)。 + 跳过头部注入消息 —— 前情摘要也是 user 角色,不算真实用户发言。""" + return sum(1 for m in self.messages[self._n_head:] if m.get("role") == "user") diff --git a/core/storage/models.py b/core/storage/models.py index ac4c7a2..dc6669c 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -95,6 +95,10 @@ class Task(Base): context_base_idx: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0" ) + # 窗口中段折叠后的结构化摘要(0021,§8.8 Phase 2)。非空时 Session.load 在 system 之后 + # 注入一条仅内存的「前情摘要」消息;与 context_base_idx 配套推进。NULL = 从未折叠。 + # 「新话题」硬重置 / 清空对话时一并清 NULL;gap 软重置保留(续聊锚点语义)。 + context_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/core/storage/usage.py b/core/storage/usage.py index 8d9b7cd..33fffc6 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -90,12 +90,15 @@ def record_chat_usage( cache_hit_cny_per_mtoken: float = 0.0, extra_units: Optional[Mapping[str, Any]] = None, response: Any = None, + kind: str = "chat", ) -> Decimal: """记一次 chat 调用:写 usage_events 行 + 回填 messages.model_profile/tokens_in/out。 `message_id` 来自 `Session.append` 的返回值;若为 None(系统消息 / 旧路径未拿到) 则 usage_events 仍写但 message_id=NULL,messages 列不回填。 `model_profile` 形如 `"deepseek_v4.pro"`(family.variant)。 + `kind` 默认 "chat";chat 形态的辅助调用可传别的标签(如 "context_fold"), + 按 kind GROUP BY 对账 + 派生统计(折叠次数 = count)—— prompt_optimize 同范式。 返回算出的 cost_cny(已落库),调用方可用作 SSE 显示。 """ cost_usd = _safe_chat_cost_usd(response) @@ -128,7 +131,7 @@ def record_chat_usage( user_id=user_id, task_id=task_id, message_id=message_id, - kind="chat", + kind=kind, model_profile=model_profile, units=units, cost_cny=cost_cny, diff --git a/core/wechat/service.py b/core/wechat/service.py index 79746f4..9441d6e 100644 --- a/core/wechat/service.py +++ b/core/wechat/service.py @@ -372,9 +372,11 @@ NEW_TOPIC_COMMANDS = frozenset({"新话题", "新会话", "/new", "清空上下 def reset_channel_context(task_id: UUID, *, hard: bool) -> int: """推进 task 的 context_base_idx(软重置),返回新 base。不删任何消息。 - hard=True(手动「新话题」):base = 总消息数 → 下一条入站起彻底新会话。 + hard=True(手动「新话题」):base = 总消息数 → 下一条入站起彻底新会话; + 顺带清 context_summary(0021)—— 彻底从零,旧摘要不再注入。 hard=False(自动 gap):base = 最后一条 user 消息 idx → 新窗口仍带上「上一轮」原文, 续聊接得上;无 user 消息(理论上不会)退化为总数。 + 摘要保留(与"留上一轮做续聊锚点"同语义)。 """ with session_scope() as s: total = s.execute( @@ -390,9 +392,10 @@ def reset_channel_context(task_id: UUID, *, hard: bool) -> int: ) ).scalar_one_or_none() new_base = int(last_user_idx) if last_user_idx is not None else int(total) - s.execute( - update(Task).where(Task.task_id == task_id).values(context_base_idx=new_base) - ) + vals: dict = {"context_base_idx": new_base} + if hard: + vals["context_summary"] = None + s.execute(update(Task).where(Task.task_id == task_id).values(**vals)) return new_base diff --git a/db/migrations/versions/20260709_1000_0021_task_context_summary.py b/db/migrations/versions/20260709_1000_0021_task_context_summary.py new file mode 100644 index 0000000..bfc5fcd --- /dev/null +++ b/db/migrations/versions/20260709_1000_0021_task_context_summary.py @@ -0,0 +1,33 @@ +"""tasks.context_summary 列(§8.8 Phase 2 阈值结构化摘要). + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-07-09 + +给 tasks 加 context_summary(TEXT NULL):窗口中段折叠后的结构化摘要。 +Session.load 时若非空,在 system 之后注入一条仅内存的「前情摘要」消息; +与 context_base_idx(0019)配套 —— 折叠 = 摘要写这列 + base 推进到切点。 +原始消息仍全量留 messages 表(边界而非删除),web 端照旧翻完整历史。 +NULL = 从未折叠,行为与 0019 之前完全一致。additive,无数据迁移。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0021" +down_revision: Union[str, None] = "0020" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("context_summary", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "context_summary") diff --git a/tests/test_context_fold.py b/tests/test_context_fold.py new file mode 100644 index 0000000..9e62f6b --- /dev/null +++ b/tests/test_context_fold.py @@ -0,0 +1,204 @@ +"""§8.8 Phase 2 折叠逻辑单测:切点选择 / 内存-prepared 映射 / Session.apply_fold / +maybe_fold 编排(DB 与 LLM 全打桩,不碰真实 PG)。""" +import unittest +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +import core.context_fold as cf +from core.context_fold import ( + FOLD_TAIL_RATIO, + MIN_FOLD_MESSAGES, + SUMMARY_NOTE_HEADER, + _kth_user_index, + _user_ordinal, + build_summary_note, + find_cutoff, +) +from core.session import Session + + +def _mk_window(n_turns: int, content_chars: int = 200) -> list: + """构造 n_turns 轮 user/assistant 对话(等长消息,方便按条数推算体量占比)。""" + msgs = [] + for i in range(n_turns): + msgs.append({"role": "user", "content": f"u{i:03d}" + "x" * content_chars}) + msgs.append({"role": "assistant", "content": f"a{i:03d}" + "y" * content_chars}) + return msgs + + +class FindCutoffTests(unittest.TestCase): + def test_cutoff_is_user_message_and_tail_ratio_respected(self) -> None: + messages = [{"role": "system", "content": "rules"}] + _mk_window(30) + cutoff = find_cutoff(messages, head_len=1) + self.assertIsNotNone(cutoff) + self.assertEqual(messages[cutoff].get("role"), "user") + # 尾部(切点起)体量 >= FOLD_TAIL_RATIO(等长消息按条数近似) + tail = len(messages) - cutoff + window = len(messages) - 1 + self.assertGreaterEqual(tail / window, FOLD_TAIL_RATIO * 0.9) + # 可折叠段不为空且够长 + self.assertGreaterEqual(cutoff - 1, MIN_FOLD_MESSAGES) + + def test_too_few_messages_returns_none(self) -> None: + messages = [{"role": "system", "content": "rules"}] + _mk_window(3) + self.assertIsNone(find_cutoff(messages, head_len=1)) + + def test_empty_window_returns_none(self) -> None: + self.assertIsNone(find_cutoff([{"role": "system", "content": "s"}], head_len=1)) + + def test_cutoff_never_splits_tool_pairing(self) -> None: + # assistant.tool_calls 后面跟 tool 结果 —— 切点是 user 消息,天然不落在中间 + messages = [{"role": "system", "content": "rules"}] + for i in range(20): + messages.append({"role": "user", "content": f"u{i}" + "x" * 200}) + messages.append({ + "role": "assistant", "content": None, + "tool_calls": [{"id": f"tc{i}", "function": {"name": "shell"}}], + }) + messages.append({"role": "tool", "tool_call_id": f"tc{i}", "content": "r" * 200}) + cutoff = find_cutoff(messages, head_len=1) + self.assertIsNotNone(cutoff) + self.assertEqual(messages[cutoff]["role"], "user") + + +class UserOrdinalMappingTests(unittest.TestCase): + def test_ordinal_roundtrip_across_prepared_list(self) -> None: + """prepare 补占位 tool 消息后 index 平移,但「第 k 条 user」两边一致。""" + messages = [ + {"role": "system", "content": "s"}, + {"role": "user", "content": "q1"}, + # 悬空 tool_call:prepare 的 repair 会在其后补一条占位 tool 消息 + {"role": "assistant", "content": None, + "tool_calls": [{"id": "tc1", "function": {"name": "shell"}}]}, + {"role": "user", "content": "q2"}, + {"role": "assistant", "content": "a2"}, + ] + from core.context import prepare_messages_with_stats + prepared, stats = prepare_messages_with_stats(messages) + self.assertEqual(stats["repaired_tool_calls"], 1) + k = _user_ordinal(messages, 3) # q2 是第 2 条 user + self.assertEqual(k, 2) + p_idx = _kth_user_index(prepared, k) + self.assertEqual(prepared[p_idx]["content"], "q2") + self.assertEqual(p_idx, 4) # 占位 tool 插入使 index +1 + + def test_kth_user_missing_returns_none(self) -> None: + self.assertIsNone(_kth_user_index([{"role": "assistant", "content": "a"}], 1)) + + +class ApplyFoldTests(unittest.TestCase): + def test_apply_fold_rebuilds_head_and_advances_base(self) -> None: + sess = Session(task_id=uuid4(), system_prompt="SYS") + window = _mk_window(10) + sess.messages.extend(window) # 模拟 load:base=0,head=1(system) + cutoff = 11 # 内存 index,某条 user 消息 + self.assertEqual(sess.messages[cutoff]["role"], "user") + + sess.apply_fold(cutoff, "SUMMARY-TEXT") + + # 窗口 = [system] + [摘要注入] + 尾段 + self.assertEqual(sess.messages[0]["role"], "system") + self.assertTrue(sess.messages[1]["content"].startswith(SUMMARY_NOTE_HEADER)) + self.assertIn("SUMMARY-TEXT", sess.messages[1]["content"]) + self.assertEqual(sess.messages[2]["content"], window[cutoff - 1]["content"]) + # base 推进 = 折叠条数(cutoff - 旧 head 1);head 变 2(system + 摘要) + self.assertEqual(sess.context_base, cutoff - 1) + self.assertEqual(sess.context_head_len, 2) + + def test_second_fold_replaces_summary_note(self) -> None: + sess = Session(task_id=uuid4(), system_prompt="SYS") + sess.messages.extend(_mk_window(10)) + sess.apply_fold(11, "FIRST") + base_after_first = sess.context_base + sess.messages.extend(_mk_window(10)) + # head=2,再折 10 条(切到 index 12 的 user 消息) + self.assertEqual(sess.messages[12]["role"], "user") + sess.apply_fold(12, "SECOND") + notes = [m for m in sess.messages if str(m.get("content", "")).startswith(SUMMARY_NOTE_HEADER)] + self.assertEqual(len(notes), 1) # 旧摘要被折进新摘要,不叠加 + self.assertIn("SECOND", notes[0]["content"]) + self.assertEqual(sess.context_base, base_after_first + 10) + self.assertEqual(sess.context_head_len, 2) + + def test_summary_note_not_counted_as_user_msg(self) -> None: + sess = Session(task_id=uuid4(), system_prompt="SYS") + sess.messages.extend(_mk_window(10)) + before = sess.n_user_msgs() + sess.apply_fold(11, "S") + # 折掉 5 轮 user(index 1..10 里 5 条 user),注入的摘要不计入 + self.assertEqual(sess.n_user_msgs(), before - 5) + + +class _FakeLLM: + def __init__(self, reply: str) -> None: + self.reply = reply + self.last_messages = None + + def chat(self, messages, **kwargs): + self.last_messages = messages + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))], + usage=SimpleNamespace(prompt_tokens=100, completion_tokens=20), + ) + + +_FAKE_CAPS = SimpleNamespace( + reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符 + family="deepseek_v4", variant="flash", + input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0, +) + + +class MaybeFoldTests(unittest.TestCase): + def _session(self) -> Session: + sess = Session(task_id=uuid4(), system_prompt="SYS") + sess.messages.extend(_mk_window(30)) + return sess + + def test_fold_happy_path(self) -> None: + sess = self._session() + llm = _FakeLLM("## 目标\n...") + events = [] + with patch.object(cf, "persist_fold") as persist, \ + patch.object(cf, "record_chat_usage") as usage: + stats = cf.maybe_fold(sess, llm, _FAKE_CAPS, user_id=uuid4(), emit=events.append) + self.assertIsNotNone(stats) + self.assertEqual(stats["phase"], "done") + # 摘要指令是折叠调用的最后一条消息(前缀复用构造) + self.assertEqual(llm.last_messages[-1]["content"], cf.FOLD_INSTRUCTION) + # DB 持久化先行,参数与内存变更一致 + persist.assert_called_once_with(sess.task_id, stats["new_base"], "## 目标\n...") + usage.assert_called_once() + # 独立 kind:折叠次数 = count(usage_events kind='context_fold'),task 详情接口依赖 + self.assertEqual(usage.call_args.kwargs.get("kind"), "context_fold") + self.assertEqual(sess.context_base, stats["new_base"]) + phases = [e.get("phase") for e in events if e.get("type") == "context_fold"] + self.assertEqual(phases, ["start", "done"]) + self.assertLess(stats["after_chars"], stats["before_chars"]) + + def test_below_threshold_no_fold(self) -> None: + sess = self._session() + caps = SimpleNamespace(**{**_FAKE_CAPS.__dict__, "reliable_context": 10_000_000}) + llm = _FakeLLM("unused") + with patch.object(cf, "persist_fold") as persist: + self.assertIsNone(cf.maybe_fold(sess, llm, caps, user_id=uuid4(), emit=lambda e: None)) + persist.assert_not_called() + self.assertIsNone(llm.last_messages) + + def test_empty_summary_skips_fold(self) -> None: + sess = self._session() + before = list(sess.messages) + events = [] + with patch.object(cf, "persist_fold") as persist: + result = cf.maybe_fold( + sess, _FakeLLM(" "), _FAKE_CAPS, user_id=uuid4(), emit=events.append + ) + self.assertIsNone(result) + persist.assert_not_called() + self.assertEqual(sess.messages, before) # 内存零污染 + self.assertTrue(any(e.get("type") == "warn" for e in events)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.py b/web/app.py index f61a24a..18d2360 100644 --- a/web/app.py +++ b/web/app.py @@ -2117,7 +2117,19 @@ def create_app() -> FastAPI: @app.get("/v1/tasks/{task_id}", tags=["tasks"]) def get_task(task_id: str, user_id: UUID = Depends(require_user)): - """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。""" + """单 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' 计数)。前端头部压缩指示环用。 + """ + from sqlalchemy import Text as SAText, cast as sa_cast + + from core.context import CHARS_PER_TOKEN + from core.storage.models import UsageEvent + try: tid = UUID(task_id) except ValueError: @@ -2132,7 +2144,35 @@ def create_app() -> FastAPI: select(func.count()).select_from(Message).where(Message.task_id == tid) ).scalar_one() usage = _usage_aggregates(s, [tid]) - return _task_dict(row, n_messages=n, usage=usage.get(tid)) + window_chars = s.execute( + select(func.coalesce(func.sum(func.length(sa_cast(Message.payload, SAText))), 0)) + .where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0)) + ).scalar_one() + folds = s.execute( + select(func.count()).select_from(UsageEvent) + .where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold") + ).scalar_one() + d = _task_dict(row, n_messages=n, usage=usage.get(tid)) + # 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None, + # 前端画灰环,不 500。 + limit_chars = None + try: + from core.agent_builder import load_config + from core.capabilities import ModelCapabilities + from core.paths import ROOT + cfg = load_config() + 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) + except Exception: + pass + d["context_window_chars"] = int(window_chars) + d["context_limit_chars"] = limit_chars + d["context_pressure"] = ( + round(int(window_chars) / limit_chars, 4) if limit_chars else None + ) + d["context_folds"] = int(folds) + return d @app.get("/v1/folders", tags=["folders"]) def list_folders(user_id: UUID = Depends(require_user)): @@ -2753,8 +2793,10 @@ def create_app() -> FastAPI: cost_cny=0, run_status="idle", run_error=None, - # 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019) + # 全删后 idx 从 0 重起,base 必须归零否则 load 窗口起点悬空(0019); + # 摘要一并清,否则清空后的对话还会被注入旧前情摘要(0021) context_base_idx=0, + context_summary=None, ) ) task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 3c4b277..9292a72 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -7,7 +7,7 @@ import { state, LS_TASK_FILTERS_COLLAPSED } from "./state.js"; import { $, showMenu } from "./dom.js"; import { api } from "./api.js"; -import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats } from "./format.js"; +import { escapeHtml, fmtTime, fmtTokens, fmtTimeAgo, taskUsageTooltip, formatTaskUsage, formatContextStats, formatUsageStats, ctxRingHtml } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; import { mqPhone, setMobileView } from "./layout.js"; @@ -470,6 +470,13 @@ function renderConcurrentWarning() { el.style.display = "block"; } +// 压缩指示环就地重绘(run 中 llm_start / context_fold 事件驱动,不整行重渲 meta — +// 避免打断模型下拉等交互态)。taskMeta 上的 context_* 字段是唯一数据源。 +function refreshCtxRing() { + const el = $("ctx-ring"); + if (el && state.taskMeta) el.outerHTML = ctxRingHtml(state.taskMeta); +} + function renderChatMeta() { const t = state.taskMeta; if (!t) { $("chat-meta").innerHTML = `(未选中任务)`; return; } @@ -489,6 +496,7 @@ function renderChatMeta() { ${t.skill ? `${escapeHtml(t.skill)}` : ""} ${t.task_id.slice(0, 8)} ${formatTaskUsage(t)} + ${ctxRingHtml(t)} ${t.description ? `${escapeHtml(t.description)}` : ""} ${renderModelDropdown(t)} @@ -598,7 +606,9 @@ async function onChangeModel(ev) { const oldProfile = t.model_profile || ""; try { const updated = await api("PATCH", `/v1/tasks/${t.task_id}`, { model_profile: newProfile }); - state.taskMeta = updated; + // PATCH 返回不含 context_* 字段(仅详情端点算),merge 保住指示环数据; + // 换模型后容量略陈旧,下一次 llm_start / 重开任务自动校正 + state.taskMeta = { ...t, ...updated }; const running = updated.run_status === "running" || updated.run_status === "cancelling"; $("chat-hint").textContent = running ? `已切到 ${newProfile} · 当前 run 跑完后生效` @@ -2115,6 +2125,15 @@ function handleSseEvent(ev, asstCard, ctx) { if (t === "llm_start") { ctx.contextStats = ev.data || {}; setRunHint(ctx, formatContextStats(ctx.contextStats)); + // 每轮 LLM 起点带最新窗口体量 + 容量 → 实时刷压缩指示环 + const cs = ctx.contextStats; + if (visible && state.taskMeta && cs.context_limit_chars) { + state.taskMeta.context_window_chars = cs.context_original_chars || 0; + state.taskMeta.context_limit_chars = cs.context_limit_chars; + state.taskMeta.context_pressure = + (cs.context_original_chars || 0) / cs.context_limit_chars; + refreshCtxRing(); + } // 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示 // 「思考中 · Ns」,而不是页面静止 ensureTextSeg(ctx); @@ -2215,6 +2234,31 @@ function handleSseEvent(ev, asstCard, ctx) { if (isProducer) upgradeMediaArtifacts(asstCard); } if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧 + } else if (t === "context_fold") { + // 长会话中段折叠(§8.8 Phase 2):start 阶段在状态行提示(摘要调用要几秒,别静止), + // done 阶段插一条与 model_switch 同款的分隔线,让"上下文被整理过"在流里可见。 + const d = ev.data || {}; + if (d.phase === "start") { + setRunHint(ctx, `整理长对话上下文中…(折叠 ${d.folding_messages || 0} 条早期消息)`); + } else if (d.phase === "done") { + closeTextSeg(ctx); + const sep = document.createElement("div"); + sep.className = "context-fold muted"; + sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;"; + sep.textContent = `── 已整理长对话:${d.folded_messages || 0} 条早期消息折叠为摘要` + + `(ctx ${fmtTokens(d.before_chars || 0)}→${fmtTokens(d.after_chars || 0)} chars)──`; + asstCard.appendChild(sep); + // 折叠计数 + 折叠后体量同步到指示环(limit 不变,窗口骤降 → 环回绿) + if (visible && state.taskMeta) { + state.taskMeta.context_folds = (state.taskMeta.context_folds || 0) + 1; + if (d.after_chars && state.taskMeta.context_limit_chars) { + state.taskMeta.context_window_chars = d.after_chars; + state.taskMeta.context_pressure = + d.after_chars / state.taskMeta.context_limit_chars; + } + refreshCtxRing(); + } + } } else if (t === "model_switch") { // skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线, // 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。 @@ -2242,6 +2286,18 @@ function handleSseEvent(ev, asstCard, ctx) { $("chat-hint").textContent = `skill 指定模型 → ${dn}`; } } + } else if (t === "warn") { + // loop 的运行时提醒(无进展熔断/重复拦截/折叠失败等)。此前被静默丢弃,与"停下 + // 不静默"的意图相悖 —— 渲染成流内 muted 单行,不打断当前文字段的排版节奏。 + const msg = (ev.data && ev.data.msg) || ""; + if (msg) { + closeTextSeg(ctx); + const w = document.createElement("div"); + w.className = "run-warn muted"; + w.style.cssText = "margin:6px 0;font-size:12px;color:var(--warn,#b8860b);"; + w.textContent = `⚠ ${msg}`; + asstCard.appendChild(w); + } } else if (t === "cancelled") { const badge = document.createElement("div"); badge.className = "cancelled-badge"; @@ -2276,7 +2332,11 @@ async function clearMessages(tid, name, nMsg) { try { const updated = await api("POST", "/v1/tasks/" + tid + "/clear"); if (state.taskId === tid) { - state.taskMeta = updated; + // clear 返回不含 context_* 字段;全删后窗口/折叠归零,指示环回空 + state.taskMeta = { + ...state.taskMeta, ...updated, + context_window_chars: 0, context_pressure: 0, context_folds: 0, + }; renderChatMeta(); renderMessages([]); state.outline = []; renderOutlineRail(); // 对话清空 → 右侧导航条(目录圆点)同步清空 diff --git a/web/static/js/format.js b/web/static/js/format.js index 6df8379..c03c121 100644 --- a/web/static/js/format.js +++ b/web/static/js/format.js @@ -53,20 +53,64 @@ export function taskUsageTooltip(t) { return lines.join("\n"); } -// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费;详情走 taskUsageTooltip。 -// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱。 +// 任务级累计用量(顶栏):只内联总 token;金额/缓存命中率挪 hover 详情 +// (taskUsageTooltip 本就有),明面槽位让给压缩指示环(ctxRingHtml,0.54.1)。 export function formatTaskUsage(t) { const tok = t.tokens || 0; if (!tok) return ""; - const hit = t.tokens_cache_hit || 0; - const pin = t.tokens_prompt || 0; - const bits = [`${fmtTokens(tok)} tok`]; - if (pin > 0 && hit > 0) { - bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`); + return `${fmtTokens(tok)} tok`; +} + +// ───── 压缩指示环(§8.8 上下文治理的头部可视化)───── +// 环 = 当前窗口体量 / 模型容量(reliable_context×2.5 chars)。色阶对应三段机制: +// 绿 <50% 未压缩(上下文原样发送);琥珀 50-85% 压缩区(发送时旧工具正文被压); +// 红 ≥85% 达折叠阈值(下次发消息 run 起点自动整理成摘要)。容量未知 → 灰空环。 +// 数据源:打开任务时 GET /v1/tasks/{id} 的 context_* 字段;run 中由 llm_start / +// context_fold 事件实时刷(chat.js refreshCtxRing)。 + +const _RING_C = 2 * Math.PI * 6; // r=6 的周长,dasharray 基准 + +export function ctxRingTitle(t) { + const p = t.context_pressure; + const lines = []; + if (typeof p === "number") { + lines.push(`上下文窗口占用 ${Math.round(p * 100)}%` + + `(${fmtTokens(t.context_window_chars || 0)} / ${fmtTokens(t.context_limit_chars || 0)} chars)`); + if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要"); + else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩"); + else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送"); + } else { + lines.push("窗口占用未知(模型容量档案不可用)"); } - const cost = fmtCost(t.cost_cny); - if (cost) bits.push(cost); - return `${bits.join(" · ")}`; + if (t.context_folds > 0) { + lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`); + } + return lines.join("\n"); +} + +export function ctxRingHtml(t) { + t = t || {}; + const p = t.context_pressure; + const known = typeof p === "number" && p >= 0; + const pct = known ? Math.min(p, 1) : 0; + const color = !known ? "#999" + : p >= 0.85 ? "#c0392b" + : p >= 0.5 ? "#d99a1b" + : "#3a9d5d"; + const label = known ? `${Math.round(p * 100)}%` : "–"; + const folds = t.context_folds || 0; + const foldBadge = folds > 0 + ? `已整理×${folds}` : ""; + return `` + + `${label}${foldBadge}`; } export function formatContextStats(d) {