fix(context): 按模型协议治理历史推理上下文
This commit is contained in:
parent
43a1b548aa
commit
5430379d76
|
|
@ -5,6 +5,12 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.62.9 — 2026-08-06
|
||||
|
||||
- 长对话不再把普通历史思考过程重复发送给模型,减少无效上下文占用;工具调用需要的推理状态仍会按模型协议保留。
|
||||
- 对话顶部上下文环改按实际模型输入统计,悬停时可查看剥离的历史思考、当前压缩的旧工具输出和累计整理次数;超出可靠容量时显示 `100%+`。
|
||||
- 新对话自动命名遇到模型服务暂时不可用时,会使用首条消息生成本地兜底标题,不再长期停留为“新对话”。
|
||||
|
||||
## 0.62.8 — 2026-08-06
|
||||
|
||||
- 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。
|
||||
|
|
|
|||
|
|
@ -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 事实源语义,不复制文件、不引不可变对象存储。
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.62.8"
|
||||
__version__ = "0.62.9"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 生效
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 期间显示
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<span style="font-size:11px;">已整理×${folds}</span>` : "";
|
||||
|
|
|
|||
Loading…
Reference in New Issue