Add Experience V1 schema migration and agent-run completion extractor.

Extend h5_experience with structured fields, wire mindspace-agent-runner and agent-run-gateway to persist task_outcome records with provenance, and add local migration and verification scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-02 10:26:46 +08:00
parent 908db04b67
commit b923e54eff
19 changed files with 1340 additions and 131 deletions
+66 -2
View File
@@ -17,6 +17,16 @@ function createMockPool() {
tagsJson,
sourceSessionId,
sourceUserId,
problem,
environmentJson,
hypothesis,
actionJson,
result,
confidence,
evidenceJson,
parentExperienceId,
supersedesId,
status,
createdAt,
updatedAt,
] = params;
@@ -30,6 +40,16 @@ function createMockPool() {
source_session_id: sourceSessionId,
source_user_id: sourceUserId,
use_count: 0,
problem,
environment_json: environmentJson,
hypothesis,
action_json: actionJson,
result,
confidence,
evidence_json: evidenceJson,
parent_experience_id: parentExperienceId,
supersedes_id: supersedesId,
status,
created_at: createdAt,
updated_at: updatedAt,
});
@@ -38,7 +58,7 @@ function createMockPool() {
if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) {
const [scope] = params;
const matched = rows
.filter((r) => r.scope === scope)
.filter((r) => r.scope === scope && r.status === 'active')
.sort((a, b) => b.updated_at - a.updated_at);
return [matched.map((r) => ({ ...r }))];
}
@@ -67,6 +87,28 @@ test('record persists an experience with normalized fields', async () => {
assert.equal(saved.kind, 'lesson');
assert.deepEqual(saved.tags, ['headscale', '网络']);
assert.equal(saved.useCount, 0);
assert.equal(saved.status, 'active');
});
test('record persists V1 structured fields', async () => {
const { pool } = createMockPool();
const svc = createExperienceService(pool, { now: () => 2000 });
const saved = await svc.record({
kind: 'task_outcome',
title: 'SSE 断线恢复',
body: 'Portal replay 需映射 Goose Last-Event-ID',
problem: 'H5 SSE 断线后消息丢失',
environment: { runtime: 'portal', components: ['goose', 'sse'] },
action: { executor: 'goose', steps: ['replay'] },
result: 'success',
confidence: 0.85,
evidence: { sources: [{ source_id: 'job:abc', source_type: 'agent_job' }] },
});
assert.equal(saved.kind, 'task_outcome');
assert.equal(saved.problem, 'H5 SSE 断线后消息丢失');
assert.deepEqual(saved.environment, { runtime: 'portal', components: ['goose', 'sse'] });
assert.equal(saved.result, 'success');
assert.equal(saved.confidence, 0.85);
});
test('record rejects empty title or body', async () => {
@@ -89,6 +131,28 @@ test('search ranks keyword matches and ignores empty queries', async () => {
assert.equal(hits[0].title, 'Headscale 部署');
});
test('search matches problem and environment haystack', async () => {
const { pool } = createMockPool();
const svc = createExperienceService(pool, { now: () => 3000 });
await svc.record({
title: '通用标题',
body: '正文不含关键词',
problem: 'MemFuse recall 排序偏低',
environment: { runtime: 'memory-v2-pgvector' },
});
const hits = await svc.search('memfuse recall');
assert.equal(hits.length, 1);
assert.equal(hits[0].problem, 'MemFuse recall 排序偏低');
});
test('search excludes non-active rows', async () => {
const { pool } = createMockPool();
const svc = createExperienceService(pool, { now: () => 4000 });
await svc.record({ title: '已归档', body: 'archived keyword match', status: 'archived' });
const hits = await svc.search('archived keyword');
assert.equal(hits.length, 0);
});
test('search applies recency decay so newer wins on equal keyword score', async () => {
const { pool } = createMockPool();
let clock = 0;
@@ -98,7 +162,7 @@ test('search applies recency decay so newer wins on equal keyword score', async
});
clock = 0;
await svc.record({ title: '部署指南 A', body: 'deploy 部署 指南' });
clock = 10_000; // 10 half-lives newer
clock = 10_000;
await svc.record({ title: '部署指南 B', body: 'deploy 部署 指南' });
clock = 10_000;
const hits = await svc.search('部署 deploy 指南', { limit: 2 });