b923e54eff
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>
207 lines
6.8 KiB
JavaScript
207 lines
6.8 KiB
JavaScript
export const EXPERIENCE_RESULTS = Object.freeze(['success', 'partial', 'failure', 'unknown']);
|
|
export const EXPERIENCE_STATUSES = Object.freeze(['active', 'archived', 'deleted']);
|
|
export const EXPERIENCE_KINDS = Object.freeze([
|
|
'lesson',
|
|
'task_outcome',
|
|
'execution_pattern',
|
|
'skill_candidate',
|
|
]);
|
|
|
|
export const EXPERIENCE_V1_MYSQL_DDL = Object.freeze([
|
|
'problem VARCHAR(512) NULL',
|
|
'environment_json JSON NULL',
|
|
'hypothesis VARCHAR(512) NULL',
|
|
'action_json JSON NULL',
|
|
"result ENUM('success','partial','failure','unknown') NULL",
|
|
'confidence DECIMAL(4,3) NULL',
|
|
'evidence_json JSON NULL',
|
|
'parent_experience_id CHAR(36) NULL',
|
|
'supersedes_id CHAR(36) NULL',
|
|
"status ENUM('active','archived','deleted') NOT NULL DEFAULT 'active'",
|
|
]);
|
|
|
|
function nonEmptyString(value, maxLen = null) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) return null;
|
|
return maxLen ? text.slice(0, maxLen) : text;
|
|
}
|
|
|
|
function normalizeConfidence(value) {
|
|
if (value == null || value === '') return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
return Math.min(1, Math.max(0, parsed));
|
|
}
|
|
|
|
function normalizeResult(value) {
|
|
const text = String(value ?? '').trim().toLowerCase();
|
|
if (!text) return null;
|
|
return EXPERIENCE_RESULTS.includes(text) ? text : null;
|
|
}
|
|
|
|
function normalizeStatus(value) {
|
|
const text = String(value ?? 'active').trim().toLowerCase();
|
|
return EXPERIENCE_STATUSES.includes(text) ? text : 'active';
|
|
}
|
|
|
|
function normalizeKind(value) {
|
|
const text = String(value ?? 'lesson').trim().toLowerCase();
|
|
return EXPERIENCE_KINDS.includes(text) ? text : 'lesson';
|
|
}
|
|
|
|
export function serializeExperienceJson(value) {
|
|
if (value == null) return null;
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
const trimmed = String(value).trim();
|
|
if (!trimmed) return null;
|
|
JSON.parse(trimmed);
|
|
return trimmed;
|
|
}
|
|
|
|
export function parseExperienceJson(value, fallback = null) {
|
|
if (value == null) return fallback;
|
|
if (typeof value === 'object') return value;
|
|
try {
|
|
return JSON.parse(String(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize caller input into stable DB-bound fields. Title/body remain required
|
|
* at the service layer; structured fields are optional.
|
|
*/
|
|
export function normalizeExperienceRecordInput(input = {}) {
|
|
return {
|
|
scope: nonEmptyString(input.scope, 64) ?? 'global',
|
|
kind: normalizeKind(input.kind),
|
|
title: nonEmptyString(input.title, 255),
|
|
body: nonEmptyString(input.body),
|
|
tags: Array.isArray(input.tags)
|
|
? [...new Set(input.tags.map((tag) => String(tag).trim()).filter(Boolean))]
|
|
: [],
|
|
sourceSessionId: nonEmptyString(input.sourceSessionId, 128),
|
|
sourceUserId: nonEmptyString(input.sourceUserId, 36),
|
|
problem: nonEmptyString(input.problem, 512),
|
|
environment: parseExperienceJson(input.environment ?? input.environmentJson, null),
|
|
hypothesis: nonEmptyString(input.hypothesis, 512),
|
|
action: parseExperienceJson(input.action ?? input.actionJson, null),
|
|
result: normalizeResult(input.result),
|
|
confidence: normalizeConfidence(input.confidence),
|
|
evidence: parseExperienceJson(input.evidence ?? input.evidenceJson, null),
|
|
parentExperienceId: nonEmptyString(input.parentExperienceId, 36),
|
|
supersedesId: nonEmptyString(input.supersedesId, 36),
|
|
status: normalizeStatus(input.status),
|
|
};
|
|
}
|
|
|
|
export function mapExperienceRow(row) {
|
|
return {
|
|
id: row.id,
|
|
scope: row.scope,
|
|
kind: row.kind,
|
|
title: row.title,
|
|
body: row.body,
|
|
tags: parseExperienceJson(row.tags_json ?? row.tags, []) ?? [],
|
|
sourceSessionId: row.source_session_id ?? null,
|
|
sourceUserId: row.source_user_id ?? null,
|
|
useCount: Number(row.use_count ?? 0),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
problem: row.problem ?? null,
|
|
environment: parseExperienceJson(row.environment_json, null),
|
|
hypothesis: row.hypothesis ?? null,
|
|
action: parseExperienceJson(row.action_json, null),
|
|
result: row.result ?? null,
|
|
confidence: row.confidence == null ? null : Number(row.confidence),
|
|
evidence: parseExperienceJson(row.evidence_json, null),
|
|
parentExperienceId: row.parent_experience_id ?? null,
|
|
supersedesId: row.supersedes_id ?? null,
|
|
status: row.status ?? 'active',
|
|
};
|
|
}
|
|
|
|
export function experienceSearchHaystack(row) {
|
|
const environmentText = row.environment_json
|
|
? JSON.stringify(parseExperienceJson(row.environment_json, {}))
|
|
: '';
|
|
return [
|
|
row.title,
|
|
row.body,
|
|
row.problem,
|
|
environmentText,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.toLowerCase();
|
|
}
|
|
|
|
export function formatExperienceInjectionBlock(hits = []) {
|
|
if (!Array.isArray(hits) || hits.length === 0) return '';
|
|
const lines = hits.map((hit) => formatExperienceInjectionLine(hit));
|
|
return `# 相关经验(供参考,来自历史任务)\n${lines.join('\n')}`;
|
|
}
|
|
|
|
export function formatExperienceInjectionLine(hit) {
|
|
const parts = [`- ${hit.title}: ${hit.body}`];
|
|
if (hit.problem) parts.push(` 问题: ${hit.problem}`);
|
|
if (hit.result) {
|
|
const conf = hit.confidence == null ? '' : `, conf=${hit.confidence}`;
|
|
parts.push(` 结果: ${hit.result}${conf}`);
|
|
}
|
|
const runtime = hit.environment?.runtime;
|
|
const components = Array.isArray(hit.environment?.components)
|
|
? hit.environment.components.filter(Boolean).join(' + ')
|
|
: '';
|
|
if (runtime || components) {
|
|
parts.push(` 环境: ${[runtime, components].filter(Boolean).join(' · ')}`);
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build a structured task_outcome payload for MindSpace agent jobs.
|
|
*/
|
|
export function buildMindspaceJobExperiencePayload({ claim, sessionId, parsed, jobId = null } = {}) {
|
|
const title = String(parsed?.title ?? claim?.instruction ?? '').trim().slice(0, 200);
|
|
const summary = String(parsed?.summary ?? '').trim();
|
|
if (!title || !summary) return null;
|
|
|
|
const runRef = jobId ? String(jobId) : null;
|
|
return normalizeExperienceRecordInput({
|
|
kind: 'task_outcome',
|
|
title,
|
|
body: summary,
|
|
problem: claim?.instruction ?? title,
|
|
result: parsed?.status === 'failed' ? 'failure' : 'success',
|
|
sourceSessionId: sessionId ?? null,
|
|
sourceUserId: claim?.userId ?? null,
|
|
environment: {
|
|
runtime: 'mindspace-agent-job',
|
|
components: ['goose', 'portal'],
|
|
},
|
|
action: {
|
|
executor: 'goose',
|
|
steps: ['agent_job_complete'],
|
|
artifacts: Array.isArray(parsed?.sourceAssetIds) ? parsed.sourceAssetIds : [],
|
|
},
|
|
evidence: {
|
|
sources: runRef
|
|
? [{
|
|
source_id: `job:${runRef}`,
|
|
source_type: 'agent_job',
|
|
actor: 'goose',
|
|
timestamp_ms: Date.now(),
|
|
modality: 'task_result',
|
|
}]
|
|
: [],
|
|
provenance: {
|
|
session_id: sessionId ?? null,
|
|
user_id: claim?.userId ?? null,
|
|
job_id: runRef,
|
|
},
|
|
},
|
|
});
|
|
}
|