feat(h5-session): Session Broker、run SSE replay 与 Finish 竞态修复

落地 H5 Session 架构 Patch 1–5(Broker 收口、Router decision、SSE taxonomy、goosed 边界检查),
并新增可选 MEMIND_RUN_STREAM_REPLAY run 事件回放与 H5 假交付 guard;修复 Finish 先于 agent-run
gate 导致 UI 永久 loading 的竞态,接入 verify:h5-session-patches 回归脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-06 14:19:48 +08:00
parent e2ad3bf62b
commit 08feae8bef
41 changed files with 2728 additions and 130 deletions
+140 -27
View File
@@ -14,6 +14,29 @@ export const CHAT_INTENT_ROUTE = {
AGENT: 'agent_orchestration',
};
export const ROUTER_DECISION_ROUTE = {
CHAT: 'chat',
AGENT: 'agent',
};
export const ROUTER_DECISION_MODE = {
SSE: 'sse',
WS: 'ws',
};
export const ROUTER_DECISION_SESSION_HINT = {
REUSE: 'reuse',
NEW: 'new',
};
const ALLOWED_ROUTER_DECISION_FLAGS = new Set([
'memory_recall',
'selected_skill',
'force_deep_reasoning',
'code_task',
'long_running',
]);
const AGENT_ORCHESTRATION_HEADER = '【Memind 任务编排】';
const SKILL_PROMPT_KEYS = {
@@ -301,11 +324,86 @@ function parseRouterJson(reply) {
function normalizeRoute(value) {
const normalized = String(value ?? '').trim().toLowerCase();
if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT) return CHAT_INTENT_ROUTE.DIRECT_CHAT;
if (normalized === CHAT_INTENT_ROUTE.AGENT || normalized === 'agent') return CHAT_INTENT_ROUTE.AGENT;
if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT || normalized === ROUTER_DECISION_ROUTE.CHAT) {
return CHAT_INTENT_ROUTE.DIRECT_CHAT;
}
if (
normalized === CHAT_INTENT_ROUTE.AGENT ||
normalized === 'agent' ||
normalized === 'agent_orchestration' ||
normalized === ROUTER_DECISION_ROUTE.AGENT
) {
return CHAT_INTENT_ROUTE.AGENT;
}
return null;
}
export function isNormalizedRouterDecisionEnabled(env = process.env) {
return envFlag(env.MEMIND_ROUTER_NORMALIZED_DECISION, false);
}
export function buildRouterNormalizedDecision(
classification,
{
sessionId = null,
sessionMessageCount = null,
forceDeepReasoning = false,
toolMode = 'chat',
text = '',
} = {},
) {
const legacyRoute = classification?.route ?? CHAT_INTENT_ROUTE.AGENT;
const route =
legacyRoute === CHAT_INTENT_ROUTE.DIRECT_CHAT
? ROUTER_DECISION_ROUTE.CHAT
: ROUTER_DECISION_ROUTE.AGENT;
const mode = ROUTER_DECISION_MODE.SSE;
let sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
if (sessionId) {
sessionHint = ROUTER_DECISION_SESSION_HINT.REUSE;
}
if (
sessionId &&
!isDirectChatSessionId(sessionId) &&
sessionMessageCount != null &&
Number(sessionMessageCount) === 0
) {
sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
}
const flags = [];
if (isMemoryRecallQuestion(text)) flags.push('memory_recall');
if (forceDeepReasoning) flags.push('force_deep_reasoning');
if (toolMode === 'code') flags.push('code_task');
if (classification?.suggestedSkill) flags.push('selected_skill');
const normalizedFlags = [...new Set(flags)].filter((flag) => ALLOWED_ROUTER_DECISION_FLAGS.has(flag));
return {
route,
mode,
session_hint: sessionHint,
flags: normalizedFlags,
};
}
export function finalizeRouterClassification(classification, context = {}) {
if (!classification) return classification;
return {
...classification,
decision: buildRouterNormalizedDecision(classification, context),
};
}
export function resolveLegacyRouteFromClassification(classification) {
if (!classification) return null;
if (isNormalizedRouterDecisionEnabled() && classification.decision?.route) {
return classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
? CHAT_INTENT_ROUTE.DIRECT_CHAT
: CHAT_INTENT_ROUTE.AGENT;
}
return classification.route ?? null;
}
export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) {
const fallbackRoute = normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE) ?? CHAT_INTENT_ROUTE.AGENT;
return {
@@ -432,23 +530,30 @@ export function classifyWithRules({
includeIntentPatterns = true,
llmRouterEnabled = false,
} = {}) {
const decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const normalized = String(text ?? '').trim();
if (forceDeepReasoning || toolMode !== 'chat') {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) {
return buildSkillSelectionClassification(userMessage);
return finalizeRouterClassification(buildSkillSelectionClassification(userMessage), decisionContext);
}
if (userMessage && !isTextOnlyUserMessage(userMessage)) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '消息包含非文本内容',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
if (
!llmRouterEnabled &&
@@ -456,42 +561,42 @@ export function classifyWithRules({
normalized &&
isMemoryRecallQuestion(normalized)
) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.98,
reason: '用户在询问个人记忆或历史对话',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '延续已有 Agent 会话',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.95,
reason: '明确需要生成或发布页面/文件',
suggested_skill: 'static-page-publish',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '简单寒暄或连通性测试',
}, { source: 'rule' });
}, { source: 'rule' }), decisionContext);
}
return null;
}
@@ -530,6 +635,7 @@ export function createChatIntentRouter(options = {}) {
memoryResolveLimit: policy.memoryResolveLimit,
timeoutMs: policy.timeoutMs,
fallbackRoute: policy.fallbackRoute,
normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env),
};
}
@@ -584,6 +690,13 @@ export function createChatIntentRouter(options = {}) {
grantedSkills = [],
} = {}) {
const text = messageDisplayText(userMessage);
const decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const ruleResult = classifyWithRules({
text,
forceDeepReasoning,
@@ -596,11 +709,11 @@ export function createChatIntentRouter(options = {}) {
});
if (ruleResult) return ruleResult;
if (!isEnabled()) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '意图路由未启用,走默认通道',
}, { source: 'fallback' });
}, { source: 'fallback' }), decisionContext);
}
const routerContext = await resolveRouterContext({
@@ -632,47 +745,47 @@ export function createChatIntentRouter(options = {}) {
'Chat intent router',
);
} catch (err) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute });
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext);
}
if (!completion?.ok) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: completion?.message ?? '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute });
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext);
}
const parsed = parseRouterJson(completion.reply);
if (!parsed) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: '意图路由响应无法解析,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute });
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }), decisionContext);
}
const classification = {
const classification = finalizeRouterClassification({
...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }),
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null,
model: completion.model ?? policy.model ?? null,
memory: routerContext,
};
}, decisionContext);
if (
classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT &&
classification.confidence < policy.minConfidence
) {
return normalizeClassification({
return finalizeRouterClassification(normalizeClassification({
...classification,
route: policy.fallbackRoute,
reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`,
}, { source: 'threshold', fallbackRoute: policy.fallbackRoute });
}, { source: 'threshold', fallbackRoute: policy.fallbackRoute }), decisionContext);
}
return classification;
}