test-memory-e2e.js
16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
#!/usr/bin/env node
/**
* Memory System — 完整端到端测试
*
* 用真实 Agent 对话验证三个 Phase 的完整效果:
*
* Phase 1 验证:
* ① 对话完成 → short-term/ 自动出现 session 文件
* ② memory.md 自动追加关键事实
* ③ 第二次对话 → Agent 能引用第一次的内容(近期记忆注入)
*
* Phase 2 验证:
* ④ 保存小脑方案 → hippocampus-index.json 更新
* ⑤ 发送匹配任务 → 海马体命中方案 → prompt 体积增大
* ⑥ 使用次数自动递增
*
* Phase 3 验证:
* ⑦ 复杂任务(多 tool call)→ 自动触发反思 → reflections/ 出现日志
* ⑧ memory.md "已验证模式" / "失败教训" 段被更新
* ⑨ 过期 short-term 合并 → long-term/ 出现合并记录
* ⑩ 日报 API → daily/{today}.md 生成
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const BASE = 'http://localhost:3000';
const AGENT = 'poet'; // 用 poet 做测试(轻量级、不影响 ceo 数据)
const DATA_DIR = path.resolve(__dirname, '../../data/agents');
const AGENT_DIR = path.join(DATA_DIR, AGENT);
const MEM = path.join(AGENT_DIR, 'memory');
let passed = 0, failed = 0, total = 0;
function test(condition, msg) {
total++;
if (condition) { passed++; console.log(` ✅ ${msg}`); }
else { failed++; console.log(` ❌ FAIL: ${msg}`); }
}
// ── HTTP 工具 ──
function post(urlStr, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const u = new URL(urlStr);
const req = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
}, res => {
let buf = '';
res.on('data', c => buf += c);
res.on('end', () => { try { resolve(JSON.parse(buf)); } catch { resolve(buf); } });
});
req.on('error', reject);
req.write(data);
req.end();
});
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function waitForStatus(agentId, convId, timeoutMs = 120000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
if (Date.now() - start > timeoutMs) return reject(new Error(`Timeout waiting for ${convId}`));
try {
const metaPath = path.join(DATA_DIR, agentId, 'conversations', convId, 'meta.json');
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
if (meta.status === 'completed' || meta.status === 'failed') return resolve(meta);
} catch {}
setTimeout(check, 2000);
};
check();
});
}
function readFile(p) { try { return fs.readFileSync(p, 'utf-8'); } catch { return ''; } }
function listDir(p) { try { return fs.readdirSync(p); } catch { return []; } }
// ════════════════════════════════════════
// Clean Slate
// ════════════════════════════════════════
function cleanMemory() {
const dirs = ['short-term', 'long-term', 'reflections', 'cerebellum', 'daily'];
for (const d of dirs) {
const dir = path.join(MEM, d);
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f === '.' || f === '..') continue;
try { fs.unlinkSync(path.join(dir, f)); } catch {}
}
}
}
// 重置 memory.md
fs.writeFileSync(path.join(MEM, 'memory.md'),
'# 我的记忆\n\n## 关键事实\n(尚无记录)\n\n## 已验证的模式\n(尚无记录)\n\n## 失败教训\n(尚无记录)\n', 'utf-8');
// 清 hippocampus
const hippIdx = path.join(MEM, 'hippocampus-index.json');
if (fs.existsSync(hippIdx)) fs.unlinkSync(hippIdx);
}
// ════════════════════════════════════════
// Main Test
// ════════════════════════════════════════
async function main() {
console.log('\n🧠 Memory System — 端到端集成测试');
console.log('═'.repeat(56));
console.log(` Agent: ${AGENT}`);
console.log(` Server: ${BASE}`);
console.log('═'.repeat(56));
// ── 0. Clean ──
console.log('\n🗑️ Step 0: 清理历史数据');
cleanMemory();
console.log(' Done.\n');
// ═══════════════════════════════════
// PHASE 1: 基础记忆读写
// ═══════════════════════════════════
console.log('┌──────────────────────────────────┐');
console.log('│ PHASE 1: 基础记忆读写 │');
console.log('└──────────────────────────────────┘');
// ── 1a. 第一次对话 ──
console.log('\n📋 1a. 发送第一次对话:让 Poet 写一首关于春天的五言绝句');
const r1 = await post(`${BASE}/api/agent/task/dispatch`, {
from: 'master', to: AGENT,
task: '请写一首关于春天的五言绝句,主题是踏青。不需要使用任何工具,直接创作即可。',
priority: 'P2', autoExecute: true,
});
test(r1.success, `Dispatch 成功 (taskId: ${r1.taskId})`);
const conv1 = r1.executionConvId;
console.log(' ⏳ 等待对话完成...');
const meta1 = await waitForStatus(AGENT, conv1);
test(meta1.status === 'completed', `对话完成 (toolCalls: ${meta1.stats?.toolCallCount})`);
await sleep(2000);
// ① session 文件
const sessions1 = listDir(path.join(MEM, 'short-term')).filter(f => f.startsWith('session-'));
test(sessions1.length >= 1, `① short-term/ 有 ${sessions1.length} 个 session 文件`);
// ② memory.md 更新
const mem1 = readFile(path.join(MEM, 'memory.md'));
const hasFact = mem1.includes('[2026-') || !mem1.includes('(尚无记录)');
test(hasFact, `② memory.md 已更新关键事实`);
// ── 1b. 第二次对话(验证近期记忆注入) ──
console.log('\n📋 1b. 发送第二次对话:要求 Agent 回忆之前写了什么');
const r2 = await post(`${BASE}/api/agent/task/dispatch`, {
from: 'master', to: AGENT,
task: '你还记得刚才写了什么内容吗?如果你的近期记忆中有相关信息,请引用它回答。不需要使用工具。',
priority: 'P2', autoExecute: true,
});
test(r2.success, `Dispatch 成功 (taskId: ${r2.taskId})`);
const conv2 = r2.executionConvId;
console.log(' ⏳ 等待对话完成...');
const meta2 = await waitForStatus(AGENT, conv2);
test(meta2.status === 'completed', `对话完成`);
await sleep(2000);
// ③ 验证 Agent 引用了之前的内容
const log2Path = path.join(AGENT_DIR, 'conversations', conv2, 'messages.log');
const log2 = readFile(log2Path);
const remembered = log2.includes('春') || log2.includes('踏青') || log2.includes('诗') || log2.includes('记忆');
test(remembered, `③ Agent 在第二次对话中引用了之前的春天/踏青内容`);
const sessions2 = listDir(path.join(MEM, 'short-term')).filter(f => f.startsWith('session-'));
test(sessions2.length >= 2, `short-term/ 现在有 ${sessions2.length} 个 session 文件`);
// ═══════════════════════════════════
// PHASE 2: 海马体检索 + 小脑
// ═══════════════════════════════════
console.log('\n┌──────────────────────────────────┐');
console.log('│ PHASE 2: 海马体检索 + 小脑固化 │');
console.log('└──────────────────────────────────┘');
// ④ 保存小脑方案
console.log('\n📋 2a. 保存一个"写诗"小脑方案');
const planRes = await post(`${BASE}/api/agent/${AGENT}/memory/cerebellum`, {
scenario: '写古典诗词',
keywords: ['写诗', '诗词', '古诗', '五言', '七言', '绝句', '律诗', '创作'],
conditions: ['需要创作古典诗词', '需要遵循格律'],
steps: [
'确认体裁(五绝/七绝/五律/七律/词)',
'确认主题和意境',
'起句: 点明时间/地点/情境',
'承句: 展开描写,丰富画面',
'转句: 转折或深化情感',
'合句: 收束全诗,升华主题',
],
notes: ['五绝20字,七绝28字', '注意平仄和押韵', '避免直白说理,用意象表达'],
confidence: 0.9,
});
test(planRes.success, `④ 小脑方案保存成功`);
// 验证 hippocampus 索引
const hippIdx = readFile(path.join(MEM, 'hippocampus-index.json'));
test(hippIdx.includes('写古典诗词'), `hippocampus-index.json 包含新条目`);
// ⑤ 发送匹配任务 → 验证海马体命中
console.log('\n📋 2b. 发送"写诗"任务 → 验证海马体检索');
const r3 = await post(`${BASE}/api/agent/task/dispatch`, {
from: 'master', to: AGENT,
task: '请写一首七言绝句,主题是中秋月夜。不需要使用工具,直接创作。',
priority: 'P2', autoExecute: true,
});
test(r3.success, `Dispatch 成功 (taskId: ${r3.taskId})`);
const conv3 = r3.executionConvId;
console.log(' ⏳ 等待对话完成...');
await waitForStatus(AGENT, conv3);
await sleep(2000);
// ⑥ 验证使用次数递增
// 海马体 useCount 在 buildSystemPrompt 时更新,需等服务端写完文件
await sleep(5000);
const hippRaw = readFile(path.join(MEM, 'hippocampus-index.json'));
console.log(` 🔍 hippocampus raw: ${hippRaw.substring(0, 200)}`);
const hippIdxAfter = JSON.parse(hippRaw || '[]');
const poetryEntry = hippIdxAfter.find(e => e.scenario === '写古典诗词');
console.log(` 🔍 poetryEntry: ${JSON.stringify(poetryEntry)}`);
test(poetryEntry && poetryEntry.useCount >= 1, `⑥ 海马体使用次数: ${poetryEntry?.useCount} (期望 ≥1)`);
test(poetryEntry && poetryEntry.lastUsed !== null, `lastUsed 已更新: ${poetryEntry?.lastUsed}`);
// ═══════════════════════════════════
// PHASE 3: 反思 + 合并 + 日报
// ═══════════════════════════════════
console.log('\n┌──────────────────────────────────┐');
console.log('│ PHASE 3: 反思 + 合并 + 日报 │');
console.log('└──────────────────────────────────┘');
// ⑦ 模拟复杂对话触发反思
console.log('\n📋 3a. 模拟复杂对话 → 验证自动反思');
const { autoReflect } = require('../../server/services/memory-service');
const complexMeta = {
status: 'completed',
stats: { toolCallCount: 8, messageCount: 4 },
createdAt: new Date(Date.now() - 180000).toISOString(),
updatedAt: new Date().toISOString(),
title: '为国庆节创作一组主题诗集,包含5首诗',
};
const complexOutput = `创作完成!以下是创作步骤:
1. 确定国庆主题和5个子题材(国旗、山河、英雄、和平、未来)
2. 分别确定每首诗的体裁(五绝、七绝、五律、七律、词)
3. 创作第一首《旗颂》五言绝句
4. 创作第二首《山河赋》七言律诗
5. 创作第三首《英雄辞》五言律诗
6. 创作第四首《和平吟》七言绝句
7. 创作第五首《展望》词·水调歌头
8. 整体校对格律和意境
产出文件:
- workspace/poet/guoqing-collection.md
- workspace/poet/guoqing-notes.md`;
const reflectResult = autoReflect(AGENT, 'test-complex', complexMeta, complexOutput);
test(reflectResult !== null, `⑦ 复杂对话触发了反思`);
test(reflectResult?.patterns?.length >= 1, `提取了 ${reflectResult?.patterns?.length} 个模式`);
test(reflectResult?.plan !== null, `自动固化了方案`);
// 验证 reflections 目录
const reflectFiles = listDir(path.join(MEM, 'reflections')).filter(f => f.startsWith('reflect-'));
test(reflectFiles.length >= 1, `reflections/ 有 ${reflectFiles.length} 个日志`);
// ⑧ memory.md 更新
const mem3 = readFile(path.join(MEM, 'memory.md'));
test(!mem3.includes('已验证的模式\n(尚无记录)'), `⑧ memory.md "已验证模式" 段有内容`);
// 模拟失败对话
console.log('\n📋 3b. 模拟失败对话 → 验证教训提取');
const failReflect = autoReflect(AGENT, 'test-fail', {
status: 'failed',
stats: { toolCallCount: 4, messageCount: 2 },
createdAt: new Date(Date.now() - 60000).toISOString(),
updatedAt: new Date().toISOString(),
title: '翻译古诗为英文但格式错误',
}, 'error: 输入格式无法解析 Error: Invalid poem format at line 3');
test(failReflect?.lessons?.length >= 1, `失败对话提取了 ${failReflect?.lessons?.length} 个教训`);
const mem3b = readFile(path.join(MEM, 'memory.md'));
test(!mem3b.includes('失败教训\n(尚无记录)'), `memory.md "失败教训" 段有内容`);
// ⑨ 记忆合并
console.log('\n📋 3c. 记忆合并 (short-term → long-term)');
// 创建 3 个"过期"session(8天前)
const shortDir = path.join(MEM, 'short-term');
const oldTs = Date.now() - 8 * 24 * 60 * 60 * 1000;
for (let i = 0; i < 3; i++) {
fs.writeFileSync(path.join(shortDir, `session-${oldTs + i}.md`),
`# Session 摘要\n\n- **时间**: 2026-03-17 0${i}:00:00\n\n## 任务\n旧任务-${i}: 写一首关于秋天的诗\n\n## 结果\n秋诗完成-${i}\n`, 'utf-8');
}
const shortBefore = listDir(shortDir).filter(f => f.startsWith('session-'));
console.log(` short-term/ 合并前: ${shortBefore.length} 文件`);
const consolidateRes = await post(`${BASE}/api/agent/${AGENT}/memory/consolidate`, {});
test(consolidateRes.merged === 3, `⑨ 合并了 ${consolidateRes.merged} 个过期 session (期望 3)`);
const ltFiles = listDir(path.join(MEM, 'long-term'));
test(ltFiles.some(f => f.startsWith('consolidate-')), `long-term/ 有合并记录`);
const shortAfter = listDir(shortDir).filter(f => {
const ts = parseInt(f.replace('session-', '').replace('.md', ''));
return !isNaN(ts) && ts < oldTs + 5;
});
test(shortAfter.length === 0, `过期 session 已从 short-term/ 删除`);
// ⑩ 日报
console.log('\n📋 3d. 日报生成');
const dailyRes = await post(`${BASE}/api/agent/${AGENT}/memory/daily-report`, {});
test(dailyRes.success, `⑩ 日报生成成功`);
const today = new Date().toISOString().substring(0, 10);
const dailyContent = readFile(path.join(MEM, 'daily', `${today}.md`));
test(dailyContent.includes('# 日报'), `daily/${today}.md 包含日报标题`);
test(dailyContent.includes('对话数'), `日报包含统计数据`);
test(dailyContent.includes('反思摘要') || dailyContent.includes('反思数'), `日报包含反思信息`);
// ═══════════════════════════════════
// 最终汇总
// ═══════════════════════════════════
console.log('\n' + '═'.repeat(56));
console.log('\n📊 Memory 目录最终状态:');
const finalState = {
'short-term/': listDir(path.join(MEM, 'short-term')).filter(f => f.endsWith('.md')).length,
'long-term/': listDir(path.join(MEM, 'long-term')).length,
'cerebellum/': listDir(path.join(MEM, 'cerebellum')).length,
'reflections/': listDir(path.join(MEM, 'reflections')).length,
'daily/': listDir(path.join(MEM, 'daily')).filter(f => f.endsWith('.md')).length,
'memory.md': `${readFile(path.join(MEM, 'memory.md')).length} 字`,
'hippocampus': hippIdxAfter.length + ' 条索引',
};
for (const [k, v] of Object.entries(finalState)) {
console.log(` 📁 ${k.padEnd(18)} ${typeof v === 'number' ? v + ' 文件' : v}`);
}
console.log('\n' + '═'.repeat(56));
const status = failed === 0 ? '🎉 ALL PASSED' : '⚠️ SOME FAILED';
console.log(`\n${status}: ${passed}/${total} 通过, ${failed} 失败\n`);
if (failed > 0) process.exit(1);
}
main().catch(err => { console.error('💥 测试崩溃:', err); process.exit(1); });