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:
+140
-27
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user