Commit 0cb55d96826d9c08222bd3bfe6ab013e8779ea84

Authored by yibo
1 parent a9d442f2

fix(agent-chat): 同 convId 恢复上下文时按模型上下文动态截断历史

原来场景 C 固定只回放最近 20 条、单条截断到 500 字,ACP 进程重启/空闲超时
后早于窗口的历史会静默丢失。改成按 token 预算(默认按 1M 上下文的一半,
AGENT_MODEL_CONTEXT_TOKENS 可覆盖)从最新往最老收,装得下就整段注入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUvQ2xSFk7YoavE5SQENdw
server/routes/agent-chat.js
... ... @@ -1067,6 +1067,30 @@ function buildUploadContext(id, convId) {
1067 1067 return contextBlock;
1068 1068 }
1069 1069  
  1070 +// 模型上下文预算 —— 现在跑的都是 1M 上下文的模型,历史记录分一半,剩下留给 system prompt / 工具产出 / 回复。
  1071 +// 换更小上下文的模型时改 AGENT_MODEL_CONTEXT_TOKENS 就行,不用改这段逻辑。
  1072 +const MODEL_CONTEXT_TOKENS = parseInt(process.env.AGENT_MODEL_CONTEXT_TOKENS || '1000000', 10);
  1073 +const HISTORY_TOKEN_BUDGET = Math.floor(MODEL_CONTEXT_TOKENS * 0.5);
  1074 +// ponytail: 4 字符≈1 token 的粗估算,不是真实分词器;预算掐得准不准全看这条,
  1075 +// 真出现估算偏差导致截断过早/过晚,再换成真实 tokenizer(如 tiktoken)计数
  1076 +const CHARS_PER_TOKEN = 4;
  1077 +function estimateTokens(text) {
  1078 + return Math.ceil(text.length / CHARS_PER_TOKEN);
  1079 +}
  1080 +
  1081 +/** 按 token 预算从最新往最老收,超预算就把更老的历史丢掉(至少保留最近一条) */
  1082 +function truncateHistoryToBudget(history, budget) {
  1083 + const kept = [];
  1084 + let used = 0;
  1085 + for (let i = history.length - 1; i >= 0; i--) {
  1086 + const cost = estimateTokens(history[i].content);
  1087 + if (kept.length > 0 && used + cost > budget) break;
  1088 + used += cost;
  1089 + kept.unshift(history[i]);
  1090 + }
  1091 + return kept;
  1092 +}
  1093 +
1070 1094 function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) {
1071 1095 // 注入上传文件上下文(按寻址名取,与上传端点的落点是同一份推导)
1072 1096 const uploadContext = buildUploadContext(agentId, convId);
... ... @@ -1086,12 +1110,11 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) {
1086 1110 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 用户消息\n\n${enrichedMessage}`;
1087 1111 }
1088 1112  
1089   - // 场景 C: 恢复上下文 — 注入历史摘要
1090   - const recentHistory = history.slice(-20);
1091   - const historyText = recentHistory.map(m => {
  1113 + // 场景 C: 恢复上下文 — 按模型上下文动态算预算,装得下就整段注入,装不下从最早的开始丢
  1114 + const boundedHistory = truncateHistoryToBudget(history, HISTORY_TOKEN_BUDGET);
  1115 + const historyText = boundedHistory.map(m => {
1092 1116 const role = m.role === 'user' ? 'Human' : 'Assistant';
1093   - const content = m.content.length > 500 ? m.content.slice(0, 500) + '...(截断)' : m.content;
1094   - return `${role}: ${content}`;
  1117 + return `${role}: ${m.content}`;
1095 1118 }).join('\n\n');
1096 1119  
1097 1120 return `${systemPrompt}${cronInstructions}\n\n---\n\n## 之前的对话历史(供你参考以保持上下文连贯)\n\n${historyText}\n\n---\n\n## 用户新消息\n\n${enrichedMessage}`;
... ... @@ -2916,3 +2939,6 @@ module.exports.saveMeta = saveMeta;
2916 2939 module.exports.appendHistory = appendHistory;
2917 2940 module.exports.logMessage = logMessage;
2918 2941 module.exports.flushLogBufferForConv = flushLogBufferForConv;
  2942 +module.exports.buildPrompt = buildPrompt;
  2943 +module.exports.truncateHistoryToBudget = truncateHistoryToBudget;
  2944 +module.exports.estimateTokens = estimateTokens;
... ...
server/tests/unit/agent-chat-history-context.test.js 0 → 100644
  1 +/**
  2 + * Guard: 同一 convId 恢复上下文时(buildPrompt 场景 C)按模型上下文 token 预算动态注入历史,
  3 + * 不再是固定的 slice(-20) + 单条 500 字截断 —— 预算内整段塞进去,超预算才从最早的开始丢。
  4 + * 否则 ACP 进程重启/空闲超时后,早于第 20 条的历史会静默丢失,agent 会"忘了"之前聊过什么。
  5 + */
  6 +const fs = require('fs');
  7 +const path = require('path');
  8 +
  9 +const { buildPrompt, appendHistory, truncateHistoryToBudget, estimateTokens } = require('../../routes/agent-chat');
  10 +
  11 +const agentId = 'master';
  12 +const agentDir = path.resolve(__dirname, `../../../system/agents/${agentId}`);
  13 +
  14 +describe('agent-chat buildPrompt 场景 C:同 convId 恢复上下文', () => {
  15 + it('注入完整历史,不按 slice(-20) 或单条 500 字截断', () => {
  16 + const convId = `test-history-guard-${Date.now()}`;
  17 + const convDir = path.resolve(__dirname, `../../../system/agents/${agentId}/_global/conversations/${convId}`);
  18 + try {
  19 + const longFirstMessage = 'X'.repeat(600); // 超过原来单条 500 字截断阈值
  20 + appendHistory(agentId, convId, { role: 'user', content: longFirstMessage });
  21 + for (let i = 0; i < 25; i++) {
  22 + appendHistory(agentId, convId, { role: 'assistant', content: `filler-${i}` });
  23 + }
  24 +
  25 + const prompt = buildPrompt(agentDir, agentId, convId, '新消息', true);
  26 +
  27 + expect(prompt).toContain(longFirstMessage);
  28 + expect(prompt).not.toContain('...(截断)');
  29 + } finally {
  30 + fs.rmSync(convDir, { recursive: true, force: true });
  31 + }
  32 + });
  33 +});
  34 +
  35 +describe('truncateHistoryToBudget:预算超支时按 token 从最早的开始丢', () => {
  36 + it('预算装不下全部历史时,丢最老的,留最近的', () => {
  37 + const history = [
  38 + { role: 'user', content: 'oldest' },
  39 + { role: 'assistant', content: 'middle' },
  40 + { role: 'user', content: 'newest' },
  41 + ];
  42 + const budget = estimateTokens('middle') + estimateTokens('newest'); // 刚好够后两条,装不下 oldest
  43 +
  44 + const kept = truncateHistoryToBudget(history, budget);
  45 +
  46 + expect(kept.map(m => m.content)).toEqual(['middle', 'newest']);
  47 + });
  48 +
  49 + it('单条消息本身就超预算时,仍然至少保留最近一条,不会返回空', () => {
  50 + const history = [{ role: 'user', content: 'X'.repeat(10000) }];
  51 +
  52 + const kept = truncateHistoryToBudget(history, 1);
  53 +
  54 + expect(kept).toHaveLength(1);
  55 + });
  56 +});
... ...