P1: 完整参数验证 + 错误处理/日志 + 测试覆盖

- 为所有 action 实现参数验证函数
- 添加 GraphMemoryLogger 日志系统(info/warn/error/action)
- 敏感数据脱敏:attributes 只记录属性名,triplets 只记录数量
- 顶层 action 参数空值检查
- 参数验证错误返回 validation_error 类型
- 添加 11 个参数验证测试用例
- 全部 118 个测试通过
This commit is contained in:
root
2026-04-24 13:56:14 +08:00
parent 135041e8b0
commit a4a904f66b
5 changed files with 712 additions and 226 deletions

View File

@ -110,6 +110,13 @@ AI 应调用 `recall` action 并返回之前写入的信息。
本项目是 OpenClaw 的图记忆插件,基于 SQLite 实现持久化图数据库。
**设计理念:增强而非替换**
本插件作为 **增强工具** 提供,不会替换 OpenClaw 的内置 memory-core 系统:
- **保留 message 数组**:对话历史仍由 memory-core 管理
- **图记忆作为工具**:通过 `graph_memory` 工具为 LLM 提供结构化记忆能力
- **两者并存**memory-core 管理会话历史graph-memory 提供知识图谱
**核心功能:**
- **recall**: 检索记忆(支持关键词、种子实体、多跳遍历、时间过滤)
- **commit**: 写入记忆(三元组批量写入)

121
TODO.md Normal file
View File

@ -0,0 +1,121 @@
# TrulyMEM OpenClaw分支 TODO
## 设计决策:作为增强工具而非主记忆核心
### 背景分析
TrulyMEM main分支的设计理念
- **图数据库作为唯一持久化记忆载体**
- **摒弃传统messages数组上下文**
- **工作记忆链(TaskNode链)代替对话历史**
- **每轮强制执行流程**:查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链
### 与OpenClaw memory-core对比
| 方面 | TrulyMEM (main分支) | OpenClaw memory-core |
|------|---------------------|---------------------|
| 核心理念 | 图数据库作为唯一持久化记忆载体 | session transcripts + memory search |
| 对话历史 | 工作记忆链(TaskNode链)代替传统messages | messages数组持久化存储 |
| 上下文管理 | 每轮从图数据库重建 + context_rewrite压缩 | compaction机制压缩历史 |
| 记忆写入 | 通过memory_commit工具写入三元组 | 自动记录对话历史 |
| 记忆检索 | memory_recall工具主动查询 | memory search索引检索 |
| 人设管理 | PersonaNode + 强制查询机制 | 无内置人设系统 |
### 作为主记忆核心的挑战
1. **架构差异**
- OpenClaw memory-core是完整基础设施管理session transcripts、health monitor等
- TrulyMEM是独立应用设计需要重新适配OpenClaw架构
2. **强制执行流程**
- TrulyMEM要求每轮必须查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链
- OpenClaw没有这种强制流程需要修改核心逻辑
3. **依赖问题**
- main分支是Python实现TUI应用
- openclaw分支是TypeScript插件不完整移植
- 需要完整移植Python版本的核心逻辑
4. **功能缺失**
- openclaw分支缺少context_rewrite压缩工具、强制执行流程、人设强制查询
- 当前只是普通tool不是完整记忆系统
### 设计决策
**短期目标**:作为增强工具
- 提供图记忆能力作为额外工具
- 不替换memory-core
- LLM可选调用
- 移除 `"kind": "memory"` 配置避免独占memory插槽
**长期目标**如果要替代memory-core
- 需要深度架构重构
- 需要完整移植main分支的核心逻辑Python → TypeScript
- 需要实现强制执行流程修改OpenClaw核心
- 需要实现context_rewrite工具
- 需要实现工作记忆链机制
- 需要先在独立项目中验证可行性
---
## 当前状态
### 已完成
- [x] plugin-entry.ts 改为OpenClaw SDK规范格式
- [x] 移除 `"kind": "memory"` 配置
- [x] README.md 更新安装文档
- [x] 插件成功加载到OpenClaw
- [x] 基本recall/commit功能测试通过
### 待完成(增强工具设计)
#### 优先级 P0 - 核心功能修复
- [ ] 确认移除memory插槽后的插件加载状态
- [ ] 测试与memory-core并存运行
- [ ] 验证工具schema正确传递给Kimi
#### 优先级 P1 - 功能完善
- [ ] 实现完整的工具参数验证
- [ ] 添加错误处理和日志
- [ ] 完善skill文档说明增强而非替换
#### 优先级 P2 - 可选高级功能
- [ ] 实现context_rewrite工具压缩上下文
- [ ] 实现工作记忆链机制
- [ ] 实现人设强制查询作为skill而非核心
- [ ] 添加时间过滤和多跳遍历优化
---
## 技术细节
### 当前实现状态
**工具列表**
- `graph_memory` - 综合工具支持多种action
- recall: 检索记忆
- commit: 写入记忆
- purge: 删除记忆
- introspect: 查看状态
- archive: 归档记忆
- cleanup: 清理数据
- persona_update/clear: 人设管理
- task_create/set_state/delete/link_info: 任务管理
**数据存储**
- SQLite数据库`~/.trulymem/graph_memory.db`
- 表结构entities, relations
**与main分支差异**
- 无context_rewrite工具
- 无强制执行流程
- 无Python TUI界面
- 纯TypeScript插件实现
---
## 参考
- main分支文档`docs/zh/memory.md`, `docs/zh/working_memory.md`
- OpenClaw文档https://docs.openclaw.ai/zh-CN/tools/plugin
- kimi-proxy`/home/program/kimi-proxy/server.js`

View File

@ -1,13 +1,15 @@
{
"id": "graph-memory",
"name": "Graph Memory",
"kind": "memory",
"description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统",
"description": "让 AI 拥有真正的长期记忆能力 - 基于图数据库的记忆系统作为增强工具不替换memory-core",
"skills": [
"bundled-skills/graph-memory",
"bundled-skills/graph-memory-persona",
"bundled-skills/graph-memory-task"
],
"contracts": {
"tools": ["graph_memory"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -63,30 +63,274 @@ export const GraphMemoryToolSchema = Type.Object({
export type GraphMemoryToolParams = Static<typeof GraphMemoryToolSchema>;
const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的长期记忆能力
const GRAPH_MEMORY_TOOL_DESCRIPTION = `图记忆工具 - 让 AI 拥有真正的长期记忆能力。作为 OpenClaw memory-core 的增强补充,不替代其核心功能。
操作:
- recall: 检索记忆
- commit: 写入记忆
- purge: 删除记忆
- introspect: 查看状态
- archive: 归档旧记忆
- cleanup: 清理无效数据
- persona_update/clear: 人设管理
- task_create/set_state/delete/link_info: 任务管理`;
- recall: 检索记忆(可选参数: queryIntent, seedEntities, depth, sessionFilter
- commit: 写入记忆(必需参数: triplets
- purge: 删除记忆(可选参数: criteria, mode, newRelation
- introspect: 查看状态(无参数)
- archive: 归档旧记忆(可选参数: days, 默认30天
- cleanup: 清理无效数据(可选参数: dry_run, 默认true预览模式
- persona_update: 更新人设(必需参数: attributes; 可选: mode=merge/replace
- persona_clear: 清除人设(必需参数: confirm=true
- task_create: 创建任务(必需参数: task_id, description; 可选: info_nodes
- task_set_state: 设置任务状态(必需参数: task_id, state
- task_delete: 删除任务(必需参数: task_id
- task_link_info: 关联信息(必需参数: task_id, info_node`;
// ==================== 参数验证 ====================
interface ValidationError {
field: string;
message: string;
}
function validateRecallParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
const queryIntent = params.queryIntent;
const seedEntities = params.seedEntities;
if ((!queryIntent || (typeof queryIntent === 'string' && queryIntent.trim() === '')) &&
(!seedEntities || !Array.isArray(seedEntities) || seedEntities.length === 0)) {
errors.push({ field: 'queryIntent/seedEntities', message: 'recall 操作需要提供 queryIntent 或 seedEntities 之一' });
}
if (params.depth !== undefined) {
const depth = Number(params.depth);
if (isNaN(depth) || depth < 1 || depth > 5) {
errors.push({ field: 'depth', message: 'depth 必须在 1-5 之间' });
}
}
return errors;
}
function validateCommitParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
const triplets = params.triplets;
if (!triplets || !Array.isArray(triplets) || triplets.length === 0) {
errors.push({ field: 'triplets', message: 'commit 操作必需提供 triplets 数组' });
return errors;
}
for (let i = 0; i < triplets.length; i++) {
const t = triplets[i] as Record<string, unknown>;
if (!t.subject || typeof t.subject !== 'string' || t.subject.trim() === '') {
errors.push({ field: `triplets[${i}].subject`, message: '三元组主体不能为空字符串' });
}
if (!t.relation || typeof t.relation !== 'string' || t.relation.trim() === '') {
errors.push({ field: `triplets[${i}].relation`, message: '三元组关系不能为空字符串' });
}
if (!t.object || typeof t.object !== 'string' || t.object.trim() === '') {
errors.push({ field: `triplets[${i}].object`, message: '三元组客体不能为空字符串' });
}
if (t.confidence !== undefined) {
const c = Number(t.confidence);
if (isNaN(c) || c < 0 || c > 1) {
errors.push({ field: `triplets[${i}].confidence`, message: '置信度必须在 0-1 之间' });
}
}
}
return errors;
}
function validatePurgeParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (params.mode === 'supersede' && (!params.newRelation || typeof params.newRelation !== 'object')) {
errors.push({ field: 'newRelation', message: 'supersede 模式必需提供 newRelation' });
}
if (params.newRelation) {
const nr = params.newRelation as Record<string, unknown>;
if (!nr.relation || typeof nr.relation !== 'string') {
errors.push({ field: 'newRelation.relation', message: 'newRelation.relation 必须是字符串' });
}
if (!nr.target || typeof nr.target !== 'string') {
errors.push({ field: 'newRelation.target', message: 'newRelation.target 必须是字符串' });
}
}
return errors;
}
function validatePersonaUpdateParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
const attributes = params.attributes;
if (!attributes || !Array.isArray(attributes) || attributes.length === 0) {
errors.push({ field: 'attributes', message: 'persona_update 操作必需提供 attributes 数组' });
return errors;
}
for (let i = 0; i < attributes.length; i++) {
const attr = attributes[i] as Record<string, unknown>;
if (!attr.attribute || typeof attr.attribute !== 'string' || attr.attribute.trim() === '') {
errors.push({ field: `attributes[${i}].attribute`, message: '属性名不能为空字符串' });
}
if (attr.value === undefined || typeof attr.value !== 'string') {
errors.push({ field: `attributes[${i}].value`, message: '属性值必须是字符串' });
}
}
return errors;
}
function validateTaskCreateParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') {
errors.push({ field: 'task_id', message: 'task_create 操作必需提供 task_id 字符串' });
}
if (!params.description || typeof params.description !== 'string') {
errors.push({ field: 'description', message: 'task_create 操作必需提供 description 字符串' });
}
return errors;
}
function validateTaskSetStateParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') {
errors.push({ field: 'task_id', message: 'task_set_state 操作必需提供 task_id 字符串' });
}
if (!params.state || typeof params.state !== 'string' || params.state.trim() === '') {
errors.push({ field: 'state', message: 'task_set_state 操作必需提供 state 字符串' });
}
return errors;
}
function validateTaskDeleteParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') {
errors.push({ field: 'task_id', message: 'task_delete 操作必需提供 task_id 字符串' });
}
return errors;
}
function validateTaskLinkInfoParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (!params.task_id || typeof params.task_id !== 'string' || params.task_id.trim() === '') {
errors.push({ field: 'task_id', message: 'task_link_info 操作必需提供 task_id 字符串' });
}
if (!params.info_node || typeof params.info_node !== 'string' || params.info_node.trim() === '') {
errors.push({ field: 'info_node', message: 'task_link_info 操作必需提供 info_node 字符串' });
}
return errors;
}
function validatePersonaClearParams(params: Record<string, unknown>): ValidationError[] {
const errors: ValidationError[] = [];
if (params.confirm === undefined) {
errors.push({ field: 'confirm', message: 'persona_clear 操作必需设置 confirm: true 才能执行清除' });
}
return errors;
}
function validateParams(action: string, params: Record<string, unknown>): ValidationError[] {
switch (action) {
case 'recall': return validateRecallParams(params);
case 'commit': return validateCommitParams(params);
case 'purge': return validatePurgeParams(params);
case 'persona_update': return validatePersonaUpdateParams(params);
case 'persona_clear': return validatePersonaClearParams(params);
case 'task_create': return validateTaskCreateParams(params);
case 'task_set_state': return validateTaskSetStateParams(params);
case 'task_delete': return validateTaskDeleteParams(params);
case 'task_link_info': return validateTaskLinkInfoParams(params);
default: return [];
}
}
// ==================== 日志 ====================
class GraphMemoryLogger {
private prefix = '[TrulyMEM]';
info(message: string, meta?: Record<string, unknown>): void {
console.log(`${this.prefix} [INFO] ${message}`, meta ? JSON.stringify(meta) : '');
}
warn(message: string, meta?: Record<string, unknown>): void {
console.warn(`${this.prefix} [WARN] ${message}`, meta ? JSON.stringify(meta) : '');
}
error(message: string, meta?: Record<string, unknown>): void {
console.error(`${this.prefix} [ERROR] ${message}`, meta ? JSON.stringify(meta) : '');
}
action(action: string, params: Record<string, unknown>, result?: unknown): void {
const meta: Record<string, unknown> = { action };
if (params && Object.keys(params).length > 0) {
// 敏感信息脱敏:不记录具体属性值
const sanitized = this.sanitizeParams(params);
meta.params = sanitized;
}
if (result !== undefined) {
meta.result = typeof result === 'object' && result !== null
? (result as Record<string, unknown>).success ?? 'ok'
: 'ok';
}
this.info(`action=${action}`, meta);
}
private sanitizeParams(params: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (key === 'attributes') {
sanitized[key] = Array.isArray(value)
? (value as Array<Record<string, unknown>>).map(a => a.attribute)
: value;
} else if (key === 'triplets') {
sanitized[key] = Array.isArray(value)
? `${(value as unknown[]).length} triplets`
: value;
} else {
sanitized[key] = value;
}
}
return sanitized;
}
}
// ==================== 主逻辑 ====================
export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
const db = new GraphDatabase(dbPath, sessionId);
const service = new MemoryService(db);
const limiter = new ToolLimiter();
const logger = new GraphMemoryLogger();
async function executeAction(action: string, params: Record<string, unknown>): Promise<unknown> {
// 1. 限流检查
const [allowed, reason] = limiter.canCall(action);
if (!allowed) {
logger.warn(`Rate limit blocked: ${action}`, { reason });
throw new Error(reason);
}
limiter.recordCall(action);
// 2. 参数验证
const validationErrors = validateParams(action, params);
if (validationErrors.length > 0) {
const errorMsg = validationErrors.map(e => `${e.field}: ${e.message}`).join('; ');
logger.warn(`Validation failed for ${action}`, { errors: validationErrors });
throw new Error(`参数验证失败: ${errorMsg}`);
}
logger.action(action, params);
switch (action) {
case 'recall':
return service.recall({
@ -120,6 +364,10 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
});
case 'persona_clear':
// confirm=false 时也要允许执行(返回 cancelled验证只拦截 confirm 不是 boolean 的情况
if (params.confirm === undefined) {
throw new Error('persona_clear 操作必需设置 confirm: true 才能执行清除');
}
return service.clearPersona({
confirm: params.confirm as boolean
});
@ -168,14 +416,34 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
content: Array<{ type: 'text'; text: string }>;
}> {
const action = params.action as string;
const actionParams = params.params as Record<string, unknown>;
const actionParams = (params.params as Record<string, unknown>) || {};
// 顶层参数验证
if (!action || typeof action !== 'string') {
logger.error('Missing or invalid action parameter', { params });
return {
content: [{
type: 'text',
text: JSON.stringify({
success: false,
error: {
type: 'validation_error',
message: '必需提供 action 参数(字符串类型)'
}
})
}]
};
}
try {
const result = await executeAction(action, actionParams);
logger.action(action, actionParams, result);
return {
content: [{ type: 'text', text: JSON.stringify({ success: true, data: result }) }]
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Execution failed: ${action}`, { error: errorMessage });
return {
content: [{
type: 'text',
@ -183,12 +451,22 @@ export function createGraphMemoryTool(dbPath?: string, sessionId?: string) {
success: false,
error: {
type: 'execution_error',
message: error instanceof Error ? error.message : String(error)
message: errorMessage
}
})
}]
};
}
},
// 供测试使用的内部方法
getLimiterSummary(): string {
return limiter.getSummary();
},
resetLimiter(): void {
limiter.reset();
logger.info('Tool limiter reset');
}
};
}

View File

@ -1,18 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GraphMemoryTool } from '../../../../../dist/runtime/core/tools/builtin/graph_memory_tool.js';
import { ToolLimiter } from '../../../../../dist/runtime/core/tools/tool_limiter.js';
import { createGraphMemoryTool, ToolLimiter } from '../../../../../dist/runtime/core/tools/builtin/graph_memory_tool.js';
import * as fs from 'fs';
const TEST_DB_PATH = '/tmp/test_graph_memory_tool.db';
describe('GraphMemoryTool', () => {
let tool: GraphMemoryTool;
let tool: ReturnType<typeof createGraphMemoryTool>;
beforeEach(() => {
if (fs.existsSync(TEST_DB_PATH)) {
fs.unlinkSync(TEST_DB_PATH);
}
tool = new GraphMemoryTool(TEST_DB_PATH, 'test-session');
tool = createGraphMemoryTool(TEST_DB_PATH, 'test-session');
});
afterEach(() => {
@ -22,24 +21,17 @@ describe('GraphMemoryTool', () => {
});
describe('Tool metadata', () => {
it('should have correct id', () => {
expect(tool.id).toBe('builtin:graph_memory');
});
it('should have correct name', () => {
expect(tool.name).toBe('GraphMemory');
expect(tool.name).toBe('graph_memory');
});
it('should have analysis category', () => {
expect(tool.category).toBe('analysis');
});
it('should have safe permission level', () => {
expect(tool.permissionLevel).toBe('safe');
it('should have description', () => {
expect(tool.description).toBeTruthy();
expect(tool.description).toContain('图记忆');
});
it('should have input schema with all actions', () => {
const actions = tool.inputSchema.properties?.action?.enum as string[];
const actions = tool.parameters.properties?.action?.enum as string[];
expect(actions).toContain('recall');
expect(actions).toContain('commit');
expect(actions).toContain('purge');
@ -55,64 +47,66 @@ describe('GraphMemoryTool', () => {
});
});
describe('recall action', () => {
it('should recall memories by query intent', async () => {
await tool.handler({
describe('execute wrapper', () => {
it('should execute recall action', async () => {
await tool.execute('call-1', {
action: 'commit',
params: {
triplets: [
{ subject: '用户', relation: '喜欢', object: '编程' }
]
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-2', {
action: 'recall',
params: {
queryIntent: '用户 编程'
}
}, {} as any);
});
const parsed = JSON.parse(result);
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
expect(result.content[0]).toHaveProperty('type', 'text');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.entities).toBeDefined();
expect(parsed.data.relations).toBeDefined();
});
it('should return empty results for non-matching query', async () => {
const result = await tool.handler({
const result = await tool.execute('call-3', {
action: 'recall',
params: {
queryIntent: '不存在的关键词xyz123'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.entities).toEqual([]);
expect(parsed.data.relations).toEqual([]);
});
});
describe('commit action', () => {
it('should commit triplets successfully', async () => {
const result = await tool.handler({
const result = await tool.execute('call-4', {
action: 'commit',
params: {
triplets: [
{ subject: '测试', relation: '是', object: '示例' }
]
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.createdEntities).toBeGreaterThan(0);
expect(parsed.data.createdRelations).toBeGreaterThan(0);
});
it('should handle multiple triplets', async () => {
const result = await tool.handler({
const result = await tool.execute('call-5', {
action: 'commit',
params: {
triplets: [
@ -121,79 +115,73 @@ describe('GraphMemoryTool', () => {
{ subject: '实体C', relation: '关联', object: '实体A' }
]
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.createdEntities).toBe(6);
expect(parsed.data.createdRelations).toBe(3);
});
});
describe('purge action', () => {
it('should purge relations by criteria', async () => {
await tool.handler({
await tool.execute('call-6', {
action: 'commit',
params: {
triplets: [
{ subject: '待删除', relation: '是', object: '测试' }
]
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-7', {
action: 'purge',
params: {
criteria: { subject: '待删除' },
mode: 'soft'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.deleted).toBeGreaterThanOrEqual(1);
expect(parsed.data.mode).toBe('soft');
});
it('should return 0 when no criteria provided', async () => {
const result = await tool.handler({
const result = await tool.execute('call-8', {
action: 'purge',
params: {}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.deleted).toBe(0);
});
});
describe('introspect action', () => {
it('should return memory statistics', async () => {
await tool.handler({
await tool.execute('call-9', {
action: 'commit',
params: {
triplets: [
{ subject: '实体1', relation: '关系', object: '实体2' }
]
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-10', {
action: 'introspect',
params: {}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.entityCount).toBeGreaterThan(0);
expect(parsed.data.relationCount).toBeGreaterThan(0);
expect(parsed.data.sessionId).toBeDefined();
});
});
describe('persona_update action', () => {
it('should update persona attributes', async () => {
const result = await tool.handler({
const result = await tool.execute('call-11', {
action: 'persona_update',
params: {
attributes: [
@ -202,310 +190,400 @@ describe('GraphMemoryTool', () => {
],
mode: 'merge'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.updatedAttributes).toBe(2);
});
it('should replace persona in replace mode', async () => {
tool.resetLimiter();
const result = await tool.handler({
action: 'persona_update',
params: {
attributes: [{ attribute: 'name', value: 'TestAI' }],
mode: 'merge'
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
expect(parsed.data.updatedAttributes).toBe(1);
expect(parsed.data.status).toBe('success');
});
});
describe('persona_clear action', () => {
it('should clear persona when confirm is true', async () => {
const result = await tool.handler({
const result = await tool.execute('call-12', {
action: 'persona_clear',
params: {
confirm: true
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.deletedCount).toBeGreaterThanOrEqual(0);
});
it('should cancel when confirm is false', async () => {
const result = await tool.handler({
const result = await tool.execute('call-13', {
action: 'persona_clear',
params: {
confirm: false
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('cancelled');
expect(parsed.data.deletedCount).toBe(0);
});
});
describe('task_create action', () => {
it('should create task successfully', async () => {
const result = await tool.handler({
const result = await tool.execute('call-14', {
action: 'task_create',
params: {
task_id: 'Task_Test123',
description: '测试任务描述'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.taskId).toBe('Task_Test123');
});
it('should create task with info_nodes', async () => {
const result = await tool.handler({
const result = await tool.execute('call-15', {
action: 'task_create',
params: {
task_id: 'Task_WithInfo',
description: '带信息的任务',
info_nodes: ['信息节点1', '信息节点2']
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.taskId).toBe('Task_WithInfo');
});
});
describe('task_set_state action', () => {
it('should set task state', async () => {
await tool.handler({
await tool.execute('call-16', {
action: 'task_create',
params: {
task_id: 'Task_StateTest',
description: '状态测试任务'
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-17', {
action: 'task_set_state',
params: {
task_id: 'Task_StateTest',
state: '已完成'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.newState).toBe('已完成');
});
});
describe('task_delete action', () => {
it('should delete task', async () => {
await tool.handler({
await tool.execute('call-18', {
action: 'task_create',
params: {
task_id: 'Task_DeleteMe',
description: '待删除任务'
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-19', {
action: 'task_delete',
params: {
task_id: 'Task_DeleteMe'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
expect(parsed.data.taskId).toBe('Task_DeleteMe');
});
});
describe('task_link_info action', () => {
it('should link info node to task', async () => {
await tool.handler({
await tool.execute('call-20', {
action: 'task_create',
params: {
task_id: 'Task_Link',
description: '链接测试任务'
}
}, {} as any);
});
const result = await tool.handler({
const result = await tool.execute('call-21', {
action: 'task_link_info',
params: {
task_id: 'Task_Link',
info_node: '新的信息节点'
}
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('success');
});
});
describe('archive action', () => {
it('should archive old relations', async () => {
const result = await tool.handler({
const result = await tool.execute('call-22', {
action: 'archive',
params: {
days: 30
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
});
describe('cleanup action', () => {
it('should cleanup in dry_run mode', async () => {
const result = await tool.handler({
action: 'cleanup',
params: {
dry_run: true
}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
it('should cleanup without dry_run', async () => {
const result = await tool.handler({
action: 'cleanup',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
});
});
describe('Unknown action error handling', () => {
it('should return error for unknown action', async () => {
const result = await tool.handler({
action: 'unknown_action',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
expect(parsed.error).toBeDefined();
expect(parsed.error.type).toBe('execution_error');
expect(parsed.error.message).toContain('Unknown action');
});
it('should handle empty action gracefully', async () => {
const result = await tool.handler({
action: '',
params: {}
}, {} as any);
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
});
});
describe('OpenClaw execute method', () => {
it('should return correct format with content array', async () => {
const result = await tool.execute('call-123', {
action: 'introspect',
params: {}
});
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
expect(result.content[0]).toHaveProperty('type', 'text');
expect(result.content[0]).toHaveProperty('text');
expect(typeof result.content[0].text).toBe('string');
});
it('should parse JSON in text content', async () => {
const result = await tool.execute('call-456', {
action: 'commit',
params: {
triplets: [
{ subject: '测试', relation: '是', object: '执行' }
]
}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should handle errors in execute format', async () => {
const result = await tool.execute('call-789', {
it('should cleanup in dry_run mode', async () => {
const result = await tool.execute('call-23', {
action: 'cleanup',
params: {
dry_run: true
}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should cleanup without dry_run', async () => {
const result = await tool.execute('call-24', {
action: 'cleanup',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
});
it('should return error for unknown action', async () => {
const result = await tool.execute('call-25', {
action: 'invalid_action',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error).toBeDefined();
});
});
describe('ToolLimiter integration', () => {
it('should block calls when rate limit exceeded', async () => {
tool.resetLimiter();
it('should handle errors in execute format', async () => {
const result = await tool.execute('call-26', {
action: '',
params: {}
});
const limiter = (tool as any).limiter;
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
});
it('should handle limiter blocking', async () => {
const newTool = createGraphMemoryTool(TEST_DB_PATH + '.limiter', 'limiter-session');
// Fill up memory_query limit
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
await newTool.execute(`limiter-call-${i}`, {
action: 'introspect',
params: {}
});
}
const result = await tool.handler({
// Next recall should be blocked
const result = await newTool.execute('limiter-blocked', {
action: 'recall',
params: { queryIntent: '测试' }
}, {} as any);
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('已达上限');
});
});
it('should get limiter summary', () => {
tool.resetLimiter();
const summary = tool.getLimiterSummary();
describe('ToolLimiter standalone', () => {
it('should limit memory query calls', () => {
const limiter = new ToolLimiter();
for (let i = 0; i < 20; i++) {
const [allowed] = limiter.canCall('recall');
if (allowed) limiter.recordCall('recall');
}
const [allowed] = limiter.canCall('recall');
expect(allowed).toBe(false);
});
it('should limit memory update calls', () => {
const limiter = new ToolLimiter();
for (let i = 0; i < 10; i++) {
const [allowed] = limiter.canCall('commit');
if (allowed) limiter.recordCall('commit');
}
const [allowed] = limiter.canCall('commit');
expect(allowed).toBe(false);
});
it('should get summary', () => {
const limiter = new ToolLimiter();
const summary = limiter.getSummary();
expect(summary).toContain('人设图');
expect(summary).toContain('工作记忆链');
expect(summary).toContain('一般记忆');
});
it('should reset limiter and allow calls again', async () => {
const limiter = (tool as any).limiter;
it('should reset and allow calls again', () => {
const limiter = new ToolLimiter();
for (let i = 0; i < 20; i++) {
limiter.recordCall('recall');
const [allowed] = limiter.canCall('recall');
if (allowed) limiter.recordCall('recall');
}
tool.resetLimiter();
expect(limiter.canCall('recall')[0]).toBe(false);
limiter.reset();
expect(limiter.canCall('recall')[0]).toBe(true);
});
});
const result = await tool.handler({
action: 'recall',
params: { queryIntent: '测试' }
}, {} as any);
describe('Parameter validation', () => {
it('should reject commit without triplets', async () => {
const result = await tool.execute('call-val-1', {
action: 'commit',
params: {}
});
const parsed = JSON.parse(result);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('triplets');
});
it('should reject commit with empty triplet fields', async () => {
const result = await tool.execute('call-val-2', {
action: 'commit',
params: {
triplets: [{ subject: '', relation: '是', object: '测试' }]
}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('subject');
});
it('should reject persona_update without attributes', async () => {
const result = await tool.execute('call-val-3', {
action: 'persona_update',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('attributes');
});
it('should reject persona_clear without confirm', async () => {
const result = await tool.execute('call-val-4', {
action: 'persona_clear',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('confirm');
});
it('should allow persona_clear with confirm=false and return cancelled', async () => {
const result = await tool.execute('call-val-4b', {
action: 'persona_clear',
params: { confirm: false }
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.data.status).toBe('cancelled');
expect(parsed.data.deletedCount).toBe(0);
});
it('should reject task_create without task_id', async () => {
const result = await tool.execute('call-val-5', {
action: 'task_create',
params: { description: '测试' }
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('task_id');
});
it('should reject task_set_state without state', async () => {
const result = await tool.execute('call-val-6', {
action: 'task_set_state',
params: { task_id: 'T1' }
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('state');
});
it('should reject recall without queryIntent and seedEntities', async () => {
const result = await tool.execute('call-val-7', {
action: 'recall',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('queryIntent');
});
it('should reject depth out of range', async () => {
const result = await tool.execute('call-val-8', {
action: 'recall',
params: { queryIntent: '测试', depth: 10 }
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('depth');
});
it('should reject commit with invalid confidence', async () => {
const result = await tool.execute('call-val-9', {
action: 'commit',
params: {
triplets: [{ subject: 'A', relation: '是', object: 'B', confidence: 2 }]
}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('置信度');
});
it('should reject purge supersede without newRelation', async () => {
const result = await tool.execute('call-val-10', {
action: 'purge',
params: { mode: 'supersede' }
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('newRelation');
});
it('should reject missing action', async () => {
const result = await tool.execute('call-val-11', {
action: '',
params: {}
});
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(false);
expect(parsed.error.message).toContain('action');
});
});
});