Skip to content

Commit 087cd7a

Browse files
authored
Merge pull request #222 from itmisx/feat/session-topic-hint
✨ session topic
2 parents 0c78be3 + 85f04de commit 087cd7a

9 files changed

Lines changed: 631 additions & 14 deletions

File tree

‎agent/llm.go‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const (
4444
compactHeadroomFloor = 30_000 // headroom 绝对下限:极小窗口也留够单轮暴涨缓冲
4545
compactHeadroomCap = 180_000 // headroom 绝对上限:大窗口不必按比例留那么多,省得过早压
4646

47+
// topicHintPct:上下文用量占窗口这个比例以上,主题一旦切换就提醒用户 /new(见 TopicHintTokens)。
48+
// 半个窗口已经在讲别的事,旧上下文对新问题就只是噪音了。
49+
topicHintPct = 50
50+
4751
// compactRetryCooldown:轮内压缩**瞬时失败**(超时/网络)后,隔这么多圈工具往返再重试。
4852
// 只作用于瞬时失败(轮数不足走永久关,不冷却),故取 4 即可:够稀释反复发压缩请求 / 反复等
4953
// 10 分钟超时,又能在长任务里较快恢复压缩。冷却期间 reclaim 不受影响、每圈照常兜底。
@@ -554,11 +558,29 @@ func coreSystemPrompt(workspace, skillCatalog string) string {
554558
return base
555559
}
556560

561+
// topicTrackingPrompt 让主 agent 每轮自报会话主题。**只给主 agent**,不进 coreSystemPrompt ——
562+
// 那段是主/子 agent 逐字节共用的缓存前缀,写进去子 agent 也会吐标签,而子 agent 的输出是当
563+
// 工具结果回灌的,标签会混进结果文本里。放在这儿:共用头部不变,主 agent 自己的前缀也仍是
564+
// 固定文本,缓存两边都不受影响。
565+
//
566+
// 只让模型「如实标注」,不让它「据此改变行为」:实测让它自己发现主题变了就改口提醒用户 /new,
567+
// 换了三种措辞一次都没触发过(它总是优先去帮用户干活);而无条件多吐一个属性稳定生效。
568+
// 要不要提醒由 tui 侧判(还要叠上模型不知道的上下文用量,见 TopicHintTokens)。
569+
const topicTrackingPrompt = `
570+
571+
# 会话主题跟踪(格式硬性要求)
572+
每轮回复的最后一行,固定输出一个主题标签,不要解释它,也不要因为它改变你的正常回答:
573+
<topic shift="no">一句话概括当前会话在做的事</topic>
574+
- 本轮和上一轮 <topic> 是同一件事(含它的子问题、追问、返工、换个角度问)→ shift="no",主题沿用或微调措辞。
575+
- 用户明确转去做另一件不相干的事 → shift="yes",<topic> 写新主题。
576+
- 会话第一轮 → shift="no"。
577+
你只负责如实标注,要不要提醒用户新开会话由 DeepX 决定,你自己不要提。`
578+
557579
// BuildSystemPrompt 主 agent 的 system prompt = 共用核心 + 会话摘要尾部。
558580
// 摘要垫在最后:核心 + skill 那段会话内字节不变,即使摘要每次压缩都变,前缀仍命中,
559581
// 失效点只从摘要开始(详见前缀缓存优化设计)。
560582
func BuildSystemPrompt(workspace, skillCatalog, summary string) string {
561-
base := coreSystemPrompt(workspace, skillCatalog)
583+
base := coreSystemPrompt(workspace, skillCatalog) + topicTrackingPrompt
562584
// 持久偏好/项目约定:读会话内冻结的快照(currentPrefs),只在启动 / 压缩时由 RefreshPreferences 刷新,
563585
// 中途每轮复用同一份 → 前缀稳定、缓存命中。会话中途写入的 AGENTS.md 下次压缩/重启才生效。
564586
if prefs := currentPrefs(); prefs != "" {
@@ -1257,6 +1279,21 @@ func CompactTriggerTokens(ctxWin int) int {
12571279
return ctxWin - headroom
12581280
}
12591281

1282+
// TopicHintTokens 返回「上下文用量达到多少 token,主题一旦切换就值得提醒用户 /new」的阈值。
1283+
//
1284+
// 提醒的价值全在「赶在压缩之前」:换会话是零成本的,而压缩是花钱去有损保留一个已经不相关的
1285+
// 话题 —— 真换了话题的会话,那笔压缩钱花得最冤。所以取窗口的 topicHintPct%,再钳到压缩阈值
1286+
// 的 80% 以下:大窗口下 50% 本来就远早于 ~70% 的压缩线,小窗口压缩来得早(64K 时 34K 就压),
1287+
// 靠这个钳位保证任何窗口下提醒都排在压缩前面。
1288+
//
1289+
// 低于这条线不提醒:上下文还小的时候换不换会话都无所谓,提醒纯属噪音。
1290+
func TopicHintTokens(ctxWin int) int {
1291+
if ctxWin <= 0 {
1292+
return 0
1293+
}
1294+
return min(ctxWin*topicHintPct/100, CompactTriggerTokens(ctxWin)*80/100)
1295+
}
1296+
12601297
// isRetryableStatus 判断 HTTP 状态码是否值得重试:429 限流、5xx 服务端错误、408/409/425 等瞬时类。
12611298
// 400/401/403/404/422 是请求本身的问题(尤其 400 常是上下文超长),重试无意义 → 直接返回错误。
12621299
func isRetryableStatus(code int) bool {

‎tui/i18n.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ var translations = map[string]map[Lang]string{
265265
LangZH: "未知沙箱模式:%s(可选 off / native / docker)",
266266
LangEN: "Unknown sandbox mode: %s (choose off / native / docker)",
267267
},
268+
// %d = 当前上下文占窗口的百分比。只有过了 TopicHintTokens 这条线才会发,所以这个数一定不小。
269+
"topic.shift": {
270+
LangZH: "会话主题已切换,建议 /new 开一段新对话 —— 旧话题的上下文对新问题只是噪音,还占着 %d%% 的窗口。",
271+
LangEN: "Topic changed — consider /new for a fresh conversation. The old context is just noise for this question, and it's holding %d%% of the window.",
272+
},
268273
"session.new": {
269274
LangZH: "已开启全新对话。上一段对话已保存,/sessions 可找回。",
270275
LangEN: "Started a new conversation. The previous one is saved — see /sessions.",
@@ -485,17 +490,19 @@ var translations = map[string]map[Lang]string{
485490
"panel.codegraph": {LangZH: "代码图谱", LangEN: "CodeGraph"},
486491
"panel.sandbox": {LangZH: "沙箱", LangEN: "Sandbox"},
487492
"panel.workmode": {LangZH: "工作模式", LangEN: "Working mode"},
493+
"panel.topic": {LangZH: "会话主题", LangEN: "Topic"},
488494
"panel.todo": {LangZH: "待办", LangEN: "Todo"}, // Todo 工具:主 agent 顺序清单
489495
"panel.plan": {LangZH: "计划", LangEN: "Plan"}, // CreatePlan:并发子 agent DAG
490496

491497
// === Right panel labels ===
492-
"panel.label.used": {LangZH: "占用", LangEN: "Used"},
493-
"panel.label.output": {LangZH: "输出", LangEN: "Output"},
494-
"panel.label.cache": {LangZH: "缓存", LangEN: "Cache"},
498+
"panel.label.used": {LangZH: "占用", LangEN: "Used"},
499+
"panel.label.output": {LangZH: "输出", LangEN: "Output"},
500+
"panel.label.cache": {LangZH: "缓存", LangEN: "Cache"},
495501
"panel.label.sbmode": {LangZH: "隔离", LangEN: "Isolation"},
496502
"panel.label.wmode": {LangZH: "方法", LangEN: "Method"},
497503
"panel.label.endpoint": {LangZH: "接口", LangEN: "Endpoint"},
498504
"panel.label.balance": {LangZH: "余额", LangEN: "Balance"},
505+
"panel.topic.none": {LangZH: "(尚未识别)", LangEN: "(not identified yet)"},
499506

500507
// === Status values ===
501508
"status.idle": {LangZH: "idle", LangEN: "idle"},

‎tui/model.go‎

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ type model struct {
172172
workingMode agent.WorkingMode // 工作模式 kp/openspec/sp(默认 kp);每轮注入对应 skill 引导;按 session 保存/恢复
173173
history []agent.ChatMessage
174174

175+
// 会话主题:模型每轮结尾输出 <topic shift="yes|no">…</topic>(见 topic.go)。会话级状态,
176+
// 跟着 /new、/sessions 走。topicF 逐 chunk 把标签从显示里剥掉并捕获主题。
177+
topic string
178+
topicF topicFilter
179+
175180
streamCh <-chan tea.Msg
176181

177182
status string
@@ -773,6 +778,7 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub
773778
gobHistory = gobHistory[1:]
774779
}
775780
m.history = gobHistory
781+
m.topic = lastTopicOf(gobHistory) // 冷启动恢复右栏主题(不额外存盘,从历史里读回)
776782
rebuildChatFromHistory(m.chatContent, gobHistory)
777783
// 老对话(升级前就有 history、没 conv.json)首次进 /sessions 别显示"(未命名)":
778784
// 用第一条用户消息回填标题。session 包自己解码不了 history.gob,放这儿做。
@@ -1180,6 +1186,7 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) {
11801186
m.streaming = true
11811187
m.thinking = true
11821188
m.currentReply.Reset()
1189+
m.topicF = topicFilter{} // 上一轮若被 ESC 打断,过滤器可能停在标签中间
11831190

11841191
// 仪表盘:开计时,快照本轮输入字符数,输出归零
11851192
m.turnStartedAt = time.Now()
@@ -2599,10 +2606,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
25992606
m.activeTool = ""
26002607
m.retryNotice = "" // 流式开始 = 重试成功,清掉提示
26012608
text := string(msg)
2602-
m.currentReply.WriteString(text)
2603-
// 上一段若是 tools(刚执行完工具,模型继续说话),切回 assistant 段。
2604-
m.chatContent.EnsureKind(kindAssistant, "")
2605-
m.chatContent.Append(text)
2609+
m.currentReply.WriteString(text) // 落盘/入历史的是原文,只有显示层剥主题标签
2610+
// 主题标签是给 DeepX 看的元信息,不显示给用户。标签可能被切在 chunk 中间,
2611+
// 故过滤器是有状态的(见 topic.go),这里逐段喂。
2612+
visible := m.topicF.feed(text)
2613+
// 整段都被标签吃掉时什么都不做 —— 开了段就会给"只输出了一行标签"的轮次留个空气泡,
2614+
// 广播出去也只是条空事件。
2615+
if visible != "" {
2616+
// 上一段若是 tools(刚执行完工具,模型继续说话),切回 assistant 段。
2617+
m.chatContent.EnsureKind(kindAssistant, "")
2618+
m.chatContent.Append(visible)
2619+
// token 事件不走 Update 顶部的通用广播(那里拿不到过滤后的文本),在这里显式发,
2620+
// 免得标签漏到浏览器面板上。
2621+
m.broadcast(web.Event{Kind: "token", Text: visible})
2622+
}
26062623
m.tokens += len([]rune(text))
26072624
m.turnOutputChars += len([]rune(text))
26082625
m.refreshViewport()
@@ -2614,6 +2631,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26142631
}
26152632
m.status = "tool"
26162633
m.retryNotice = "" // 工具开始 = 上游请求已成功,清掉重试提示
2634+
// 助手这段话说完了:把主题过滤器攒着的未决尾巴(可能是 "<topic" 的部分前缀)吐回当前段,
2635+
// 否则它会跟到下面的工具行后面去。
2636+
if tail := m.topicF.flush(); tail != "" {
2637+
m.chatContent.Append(tail)
2638+
}
26172639
// Todo 是可见清单的「全量快照更新」,不是一次干活动作:清单本身由下方 live overlay
26182640
// 实时勾选,每次更新再往 chat 打一行 "📌 Todo" 是纯噪音。所以 Todo 不留 chat 行、
26192641
// 也不计入"N 次工具调用"。其余工具照旧:紧凑单行 <icon> Name (主参数)。
@@ -2945,6 +2967,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29452967
_ = m.session.Append("assistant", final)
29462968
}
29472969
}
2970+
// 本轮收尾:先把主题过滤器的未决尾巴吐回对话区,再落地这一轮的主题 / 判是否提醒 /new。
2971+
// 尾巴只剩空白就丢掉 —— 话都说完了,补几个换行只会在气泡末尾留空行。
2972+
if tail := strings.TrimRight(m.topicF.flush(), " \t\r\n"); tail != "" {
2973+
m.chatContent.Append(tail)
2974+
}
2975+
ctxWin := m.models.Pro.ContextWindow
2976+
if ctxWin <= 0 {
2977+
ctxWin = 65536
2978+
}
2979+
m.applyTurnTopic(ctxWin)
2980+
29482981
m.status = "idle"
29492982
m.streaming = false
29502983
m.thinking = false
@@ -2961,10 +2994,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
29612994
// 这里无需额外动作 — 旧的 trimDisplayTurns 按"10 轮"裁的逻辑已被 chatLog 取代。
29622995

29632996
// 检查是否需要触发会话压缩:上下文用量达到 agent.CompactTriggerTokens(动态阈值)就压。
2964-
ctxWin := m.models.Pro.ContextWindow
2965-
if ctxWin <= 0 {
2966-
ctxWin = 65536
2967-
}
2997+
// ctxWin 在上面判主题提醒时已经算好。
29682998
if m.session != nil && m.models.Pro.Model != "" && !m.compacting && m.lastPromptTokens() >= agent.CompactTriggerTokens(ctxWin) {
29692999
m.compacting = true
29703000
m.compactingFG = true // 前台阻塞:同手动 /compact,footer 转 spinner + 期间挡输入。子 agent 走 runSubAgent 不经此处,天然例外。
@@ -3374,6 +3404,9 @@ func (m *model) appendChat(role, text string) {
33743404
// chatDisplayText 取一条消息用于"对话区显示"的文本:优先 Content,否则从 ContentParts 拼出文本、图片用 [图片] 表示。
33753405
func chatDisplayText(msg agent.ChatMessage) string {
33763406
if msg.Content != "" {
3407+
if msg.Role == "assistant" {
3408+
return stripTopicTags(msg.Content) // 历史存的是原文,重放到对话区时才剥主题标签
3409+
}
33773410
return msg.Content
33783411
}
33793412
var sb strings.Builder
@@ -4190,6 +4223,7 @@ func (m *model) startWorkflowTurn(rawInput, name string, args any) tea.Cmd {
41904223
m.streaming = true
41914224
m.thinking = true
41924225
m.currentReply.Reset()
4226+
m.topicF = topicFilter{}
41934227
m.turnStartedAt = time.Now()
41944228
m.turnInputChars = sumHistoryChars(m.history)
41954229
m.turnOutputChars = 0

‎tui/session_modal.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ func (m *model) loadCurrentConversation() {
8686
m.plan = nil
8787
m.planKind = ""
8888
m.pendingUserText = ""
89+
// 主题是会话级状态(同 workingMode / modelPin),跟着会话走,不跨会话串。
90+
// 不用管 topicF:切会话在流式中被拒,而每轮开头都会把它清零。
91+
m.topic = ""
8992
// 切会话:影子档位归零;代数 +1 让另一会话在飞的影子结果 gen 失配被丢弃(不串台存错会话)。
9093
m.shadowDonePct = 0
9194
m.compactGen++
@@ -107,6 +110,7 @@ func (m *model) loadCurrentConversation() {
107110
gobHistory = gobHistory[1:]
108111
}
109112
m.history = gobHistory
113+
m.topic = lastTopicOf(gobHistory) // 右栏主题跟着切过去的会话恢复
110114
rebuildChatFromHistory(m.chatContent, gobHistory)
111115
}
112116
}

0 commit comments

Comments
 (0)