fix(context): 按模型协议治理历史推理上下文

This commit is contained in:
caoqianming 2026-08-06 16:43:20 +08:00
parent 43a1b548aa
commit 5430379d76
17 changed files with 303 additions and 27 deletions

View File

@ -5,6 +5,12 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.62.9 — 2026-08-06
- 长对话不再把普通历史思考过程重复发送给模型,减少无效上下文占用;工具调用需要的推理状态仍会按模型协议保留。
- 对话顶部上下文环改按实际模型输入统计,悬停时可查看剥离的历史思考、当前压缩的旧工具输出和累计整理次数;超出可靠容量时显示 `100%+`
- 新对话自动命名遇到模型服务暂时不可用时,会使用首条消息生成本地兜底标题,不再长期停留为“新对话”。
## 0.62.8 — 2026-08-06
- 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。

View File

@ -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_dirname 可选,并可预设 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_dirname 可选,并可预设 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 事实源语义,不复制文件、不引不可变对象存储。

View File

@ -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。

View File

@ -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

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.62.8"
__version__ = "0.62.9"

View File

@ -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"

View File

@ -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

View File

@ -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",

View File

@ -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 = {

View File

@ -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"},

View File

@ -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()

View File

@ -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"

View File

@ -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:

View File

@ -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 生效

View File

@ -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

View File

@ -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 期间显示

View File

@ -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>` : "";