14a00774d9
- 新增 web 能力并挂载 platform/web(web_search/fetch_url) - 实时查询强制 web skill,router fallback 与 await session Finish - Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
1050 lines
35 KiB
JavaScript
1050 lines
35 KiB
JavaScript
import {
|
||
buildChatSkillPrompt,
|
||
buildWebNewsSkillPrompt,
|
||
extractSelectedChatSkillName,
|
||
hasExplicitChatSkillPrompt,
|
||
} from './chat-skills.mjs';
|
||
import { isDirectChatSessionId } from './direct-chat-service.mjs';
|
||
import {
|
||
memoryLimitForIntervention,
|
||
resolveMemoryInterventionMode,
|
||
} from './memory-intervention.mjs';
|
||
|
||
export const CHAT_INTENT_ROUTE = {
|
||
DIRECT_CHAT: 'direct_chat',
|
||
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 = {
|
||
web: 'web',
|
||
search: 'search',
|
||
'static-page-publish': 'generate-page',
|
||
'form-builder': 'form-builder',
|
||
'table-viewer': 'table-viewer',
|
||
'product-campaign-page': 'product-campaign-page',
|
||
};
|
||
|
||
const OBVIOUS_AGENT_PATTERNS = [
|
||
/\bpublic\/[^\s"'<>]+\.html\b/i,
|
||
/MindSpace\/[^/\s]+\/public\/[^\s"'<>]+\.html/i,
|
||
/(?:生成|创建|制作|做|写|设计|发布).{0,24}(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
|
||
/(?:H5|h5|HTML|html|网页|页面).{0,24}(?:生成|创建|制作|做|写|设计|发布)/u,
|
||
/(?:写|做|生成|制作|设计).{0,16}主题(?:页面|文章)/u,
|
||
/主题(?:页面|文章).{0,16}(?:写|做|生成|制作|设计)/u,
|
||
];
|
||
|
||
const OBVIOUS_DIRECT_PATTERNS = [
|
||
/^(?:你好|您好|在吗|在不在|嗨|hi|hello|hey)[!!。.\s]*$/iu,
|
||
/^(?:测试\s*\d*|test\s*\d*)[!!。.\s]*$/iu,
|
||
/^[??]+$/u,
|
||
];
|
||
|
||
/** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */
|
||
const OBVIOUS_DIRECT_CHAT_PATTERNS = [
|
||
/(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u,
|
||
/(?:写|作|创|生成).{0,10}(?:首|一段|一篇|一个)?(?:诗|散文|笑话|段子|故事)/u,
|
||
/(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u,
|
||
];
|
||
|
||
/** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */
|
||
const REALTIME_INFO_AGENT_PATTERNS = [
|
||
/(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u,
|
||
/(?:赛况|赛程|比分|战况|积分榜|最新比赛|比赛结果).{0,16}(?:如何|怎样|怎么样|多少|是什么)/u,
|
||
/(?:看看|查(?:一下|查)?|搜索|打听|了解).{0,16}(?:今天|今日|现在|最新|当前).{0,16}(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
|
||
/(?:今天|今日|现在|最新|当前).{0,12}(?:的)?(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
|
||
/(?:实时|最新).{0,8}(?:信息|数据|情况|动态|资讯)/u,
|
||
];
|
||
|
||
const MEMORY_RECALL_PATTERNS = [
|
||
/你(?:还)?记得(?:我)?/u,
|
||
/(?:有没有|是否).{0,6}记住/u,
|
||
/你对(?:我)?(?:的)?记忆/u,
|
||
/(?:我|之前).{0,16}(?:说过|提到|聊过|告诉)/u,
|
||
/(?:我的|之前的)(?:记忆|偏好|计划|目标|想法)/u,
|
||
/之前(?:说|提|聊)(?:过|的)/u,
|
||
];
|
||
|
||
export function isMemoryRecallQuestion(text) {
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return false;
|
||
return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized));
|
||
}
|
||
|
||
export function isRealtimeInfoQuestion(text) {
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return false;
|
||
return REALTIME_INFO_AGENT_PATTERNS.some((pattern) => pattern.test(normalized));
|
||
}
|
||
|
||
function buildRealtimeInfoClassification() {
|
||
return normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 0.98,
|
||
reason: '需要搜索实时资料',
|
||
suggested_skill: 'web',
|
||
agent_brief: REALTIME_WEB_AGENT_BRIEF,
|
||
}, { source: 'rule' });
|
||
}
|
||
|
||
export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) {
|
||
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
|
||
if (!isRealtimeInfoQuestion(text)) return classification;
|
||
if (grantedSkills.length > 0 && !grantedSkills.includes('web')) return classification;
|
||
const reason = String(classification.reason ?? '').trim();
|
||
return {
|
||
...classification,
|
||
suggestedSkill: 'web',
|
||
agentBrief: REALTIME_WEB_AGENT_BRIEF,
|
||
reason: reason.includes('web') ? reason : `${reason || '需要搜索实时资料'}(实时查询强制 web)`,
|
||
};
|
||
}
|
||
|
||
function resolveRouterFallbackRoute(env, overrides = {}) {
|
||
const raw = overrides.fallbackRoute
|
||
?? normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE)
|
||
?? CHAT_INTENT_ROUTE.AGENT;
|
||
// Timeout/parse failures must fail open to agent so realtime/tool tasks are not dropped.
|
||
return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw;
|
||
}
|
||
|
||
const DEFAULT_ROUTER_TIMEOUT_MS = 2500;
|
||
const DEFAULT_ROUTER_MEMORY_LIMIT = 8;
|
||
const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55;
|
||
const REALTIME_WEB_AGENT_BRIEF =
|
||
'先 load_skill → web;优先 web_search/fetch_url 获取实时信息。若 web_search 不可用,立即改用 fetch_url 抓取 Bing/新华网/央视等可达来源;禁止使用 search 技能查工作区,不要反复 scrape 同一站点。';
|
||
|
||
function envFlag(value, fallback = false) {
|
||
const raw = String(value ?? '').trim().toLowerCase();
|
||
if (!raw) return fallback;
|
||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||
}
|
||
|
||
function boundedNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
|
||
if (value == null || value === '') return fallback;
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return Math.min(max, Math.max(min, parsed));
|
||
}
|
||
|
||
function pickDefined(source, keys) {
|
||
return Object.fromEntries(
|
||
keys
|
||
.filter((key) => source[key] !== undefined)
|
||
.map((key) => [key, source[key]]),
|
||
);
|
||
}
|
||
|
||
function truncateText(value, maxLength) {
|
||
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||
if (!text) return '';
|
||
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
||
}
|
||
|
||
function firstSentence(value, maxLength) {
|
||
const text = truncateText(value, maxLength);
|
||
if (!text) return '';
|
||
const match = text.match(/^(.{12,}?[。.!!??])/u);
|
||
return truncateText(match?.[1] ?? text, maxLength);
|
||
}
|
||
|
||
function normalizeMemoryText(item) {
|
||
if (typeof item === 'string') return { label: null, text: item };
|
||
const text = item?.text ?? item?.memory_text ?? item?.memoryText ?? item?.content ?? item?.summary ?? '';
|
||
const label = String(item?.label ?? item?.type ?? '').trim() || null;
|
||
return { label, text };
|
||
}
|
||
|
||
function withTimeout(promise, timeoutMs, label) {
|
||
const timeout = Number(timeoutMs);
|
||
if (!Number.isFinite(timeout) || timeout <= 0) return promise;
|
||
let timer = null;
|
||
const timeoutPromise = new Promise((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
const err = new Error(`${label} timed out after ${timeout}ms`);
|
||
err.code = 'CHAT_INTENT_ROUTER_TIMEOUT';
|
||
reject(err);
|
||
}, timeout);
|
||
});
|
||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||
if (timer) clearTimeout(timer);
|
||
});
|
||
}
|
||
|
||
function messageDisplayText(message) {
|
||
const displayText = message?.metadata?.displayText;
|
||
if (typeof displayText === 'string' && displayText.trim()) return displayText.trim();
|
||
return extractMessageText(message);
|
||
}
|
||
|
||
function extractMessageText(message) {
|
||
const content = message?.content;
|
||
if (typeof content === 'string') return content.trim();
|
||
if (!Array.isArray(content)) return String(message?.text ?? message?.value ?? '').trim();
|
||
return content
|
||
.map((item) => {
|
||
if (typeof item === 'string') return item;
|
||
if (item?.type === 'text') return item.text ?? '';
|
||
return '';
|
||
})
|
||
.join('\n')
|
||
.trim();
|
||
}
|
||
|
||
function isTextOnlyUserMessage(message) {
|
||
const content = message?.content;
|
||
if (!Array.isArray(content)) return Boolean(extractMessageText(message));
|
||
if (content.length === 0) return false;
|
||
return content.every((item) => {
|
||
if (typeof item === 'string') return true;
|
||
return item?.type === 'text';
|
||
});
|
||
}
|
||
|
||
function readSelectedChatSkillId(message) {
|
||
const selected =
|
||
message?.metadata?.memindRun?.selectedChatSkill ??
|
||
message?.metadata?.selectedChatSkill;
|
||
return typeof selected === 'string' && selected.trim() ? selected.trim() : null;
|
||
}
|
||
|
||
function messageHasExplicitChatSkillSelection(message) {
|
||
if (!message) return false;
|
||
if (readSelectedChatSkillId(message)) return true;
|
||
return [extractMessageText(message), messageDisplayText(message)].some((text) =>
|
||
hasExplicitChatSkillPrompt(text),
|
||
);
|
||
}
|
||
|
||
function buildSkillSelectionClassification(userMessage) {
|
||
const selectedSkillId = readSelectedChatSkillId(userMessage);
|
||
const suggestedSkill =
|
||
selectedSkillId ??
|
||
extractSelectedChatSkillName(extractMessageText(userMessage)) ??
|
||
extractSelectedChatSkillName(messageDisplayText(userMessage));
|
||
return normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 1,
|
||
reason: '用户已选择 skill',
|
||
suggested_skill: suggestedSkill,
|
||
agent_brief: suggestedSkill ? `使用 ${suggestedSkill} 执行用户任务` : '执行用户选择的 skill 任务',
|
||
}, { source: 'rule' });
|
||
}
|
||
|
||
function buildRouterSystemPrompt(grantedSkills = []) {
|
||
const skills = Array.isArray(grantedSkills) ? grantedSkills.filter(Boolean) : [];
|
||
return [
|
||
'你是 TKMind H5 聊天意图路由器,只负责判断用户消息应该走哪条处理通道。',
|
||
'',
|
||
'系统有两条通道:',
|
||
'1. direct_chat — 纯文字交流:问答、解释、闲聊、总结已有文字、给建议,不需要写文件、生成页面、调工具。',
|
||
'2. agent_orchestration — 任务编排 Agent:需要实际执行并产出结果的任务,例如生成/修改 HTML 页面、发布到 MindSpace、搜索实时资料、写代码改仓库、表单收集、数据表格、商品页、docx/长图导出等。',
|
||
'',
|
||
'判断原则:',
|
||
'- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat',
|
||
'- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration',
|
||
'- 用户询问实时赛况、新闻、天气、行情等需要联网查询的问题 → agent_orchestration,suggested_skill 填 web(不要填 search)',
|
||
'- 不确定时优先 agent_orchestration,避免漏执行',
|
||
'- 记忆线索只用于辅助判断本轮意图,不能替用户扩写新需求',
|
||
'',
|
||
skills.length ? `当前用户已授权 skills:${skills.join(', ')}` : '当前用户未授权额外 skills。',
|
||
skills.length
|
||
? '若走 agent_orchestration,可在 suggested_skill 中填写最匹配的 skill 名称(须来自上述列表),否则填 null。'
|
||
: 'suggested_skill 通常填 null。',
|
||
'',
|
||
'只输出 JSON,不要 markdown,不要解释:',
|
||
'{"route":"direct_chat|agent_orchestration","confidence":0.0,"reason":"一句话","suggested_skill":null,"agent_brief":"给 Agent 的执行要点,direct_chat 时可为空"}',
|
||
].join('\n');
|
||
}
|
||
|
||
export function buildRouterContext(resolveResult, {
|
||
memoryLimit = 5,
|
||
semanticLimit = 3,
|
||
goalLimit = 2,
|
||
memoryTextLimit = 80,
|
||
semanticTextLimit = 60,
|
||
goalTextLimit = 60,
|
||
behaviorTextLimit = 100,
|
||
} = {}) {
|
||
const result = resolveResult && typeof resolveResult === 'object' ? resolveResult : {};
|
||
const lines = [];
|
||
let itemsUsed = 0;
|
||
|
||
const memories = Array.isArray(result.memories) ? result.memories : [];
|
||
const memoryLines = memories
|
||
.map((item) => normalizeMemoryText(item))
|
||
.map(({ label, text }) => {
|
||
const clipped = truncateText(text, memoryTextLimit);
|
||
if (!clipped) return '';
|
||
return `- ${label ? `[${label}] ` : ''}${clipped}`;
|
||
})
|
||
.filter(Boolean)
|
||
.slice(0, memoryLimit);
|
||
if (memoryLines.length) {
|
||
lines.push('相关记忆:', ...memoryLines);
|
||
itemsUsed += memoryLines.length;
|
||
}
|
||
|
||
const semanticLines = (Array.isArray(result.semanticMemories) ? result.semanticMemories : [])
|
||
.map((item) => (typeof item === 'string' ? item : normalizeMemoryText(item).text))
|
||
.map((text) => truncateText(text, semanticTextLimit))
|
||
.filter(Boolean)
|
||
.slice(0, semanticLimit)
|
||
.map((text) => `- ${text}`);
|
||
if (semanticLines.length) {
|
||
lines.push('语义线索:', ...semanticLines);
|
||
itemsUsed += semanticLines.length;
|
||
}
|
||
|
||
const behaviorSummary = firstSentence(result.behaviorSummary, behaviorTextLimit);
|
||
if (behaviorSummary) {
|
||
lines.push(`行为摘要:${behaviorSummary}`);
|
||
itemsUsed += 1;
|
||
}
|
||
|
||
const goals = Array.isArray(result.activeGoals) && result.activeGoals.length
|
||
? result.activeGoals
|
||
: (Array.isArray(result.contextGoals) ? result.contextGoals : []);
|
||
const goalLines = goals
|
||
.map((text) => truncateText(text, goalTextLimit))
|
||
.filter(Boolean)
|
||
.slice(0, goalLimit)
|
||
.map((text) => `- ${text}`);
|
||
if (goalLines.length) {
|
||
lines.push('进行中目标:', ...goalLines);
|
||
itemsUsed += goalLines.length;
|
||
}
|
||
|
||
return {
|
||
content: lines.join('\n').trim(),
|
||
itemsUsed,
|
||
source: result.source ?? null,
|
||
degraded: Boolean(result.degraded),
|
||
skipped: Boolean(result.skipped),
|
||
reason: result.reason ?? null,
|
||
};
|
||
}
|
||
|
||
function buildRouterUserPrompt({ text, routerContext }) {
|
||
const context = String(routerContext ?? '').trim() || '无';
|
||
return [
|
||
'[Router Context]',
|
||
context,
|
||
'',
|
||
'[User]',
|
||
text || '(empty)',
|
||
].join('\n');
|
||
}
|
||
|
||
function parseRouterJson(reply) {
|
||
const text = String(reply ?? '').trim();
|
||
if (!text) return null;
|
||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
const candidate = (fenced?.[1] ?? text).trim();
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch {
|
||
const start = candidate.indexOf('{');
|
||
const end = candidate.lastIndexOf('}');
|
||
if (start < 0 || end <= start) return null;
|
||
try {
|
||
return JSON.parse(candidate.slice(start, end + 1));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeRoute(value) {
|
||
const normalized = String(value ?? '').trim().toLowerCase();
|
||
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 resolveNormalizedRouterDecisionMode(env = process.env) {
|
||
const raw = String(env?.MEMIND_ROUTER_NORMALIZED_DECISION ?? '').trim().toLowerCase();
|
||
if (!raw || raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return 'off';
|
||
if (raw === 'shadow') return 'shadow';
|
||
if (['1', 'true', 'yes', 'on'].includes(raw)) return 'on';
|
||
return 'off';
|
||
}
|
||
|
||
export function isNormalizedRouterDecisionEnabled(env = process.env) {
|
||
return resolveNormalizedRouterDecisionMode(env) === 'on';
|
||
}
|
||
|
||
export function isNormalizedRouterDecisionShadow(env = process.env) {
|
||
return resolveNormalizedRouterDecisionMode(env) === 'shadow';
|
||
}
|
||
|
||
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 logRouterDecisionShadow(
|
||
classification,
|
||
{ logger = console, requestId = null, sessionId = null, env = process.env } = {},
|
||
) {
|
||
if (!isNormalizedRouterDecisionShadow(env) || !classification?.decision) return;
|
||
const legacyRoute = classification.route ?? null;
|
||
const normalizedLegacyRoute =
|
||
classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
|
||
? CHAT_INTENT_ROUTE.DIRECT_CHAT
|
||
: CHAT_INTENT_ROUTE.AGENT;
|
||
const payload = {
|
||
requestId,
|
||
sessionId,
|
||
legacyRoute,
|
||
normalizedRoute: normalizedLegacyRoute,
|
||
decision: classification.decision,
|
||
source: classification.source ?? null,
|
||
reason: classification.reason ?? null,
|
||
wouldChangeRoute: legacyRoute != null && legacyRoute !== normalizedLegacyRoute,
|
||
};
|
||
const line = `[router-shadow] ${JSON.stringify(payload)}`;
|
||
if (typeof logger.info === 'function') logger.info(line);
|
||
else logger.log(line);
|
||
}
|
||
|
||
/**
|
||
* Apply router session_hint for goosed agent path without overriding hard rules.
|
||
* Returns null when gateway should start a fresh goosed session.
|
||
*/
|
||
export function resolveGatewayAgentSessionId({
|
||
agentSessionId = null,
|
||
classification = null,
|
||
forceDeepReasoning = false,
|
||
enabled = isNormalizedRouterDecisionEnabled(),
|
||
} = {}) {
|
||
const sessionId = agentSessionId || null;
|
||
|
||
if (forceDeepReasoning) {
|
||
return sessionId;
|
||
}
|
||
|
||
if (!enabled || !classification?.decision) {
|
||
return sessionId;
|
||
}
|
||
|
||
const decision = classification.decision;
|
||
if (Array.isArray(decision.flags) && decision.flags.includes('force_deep_reasoning')) {
|
||
return sessionId;
|
||
}
|
||
|
||
if (!sessionId) {
|
||
return null;
|
||
}
|
||
|
||
if (isDirectChatSessionId(sessionId)) {
|
||
return sessionId;
|
||
}
|
||
|
||
if (decision.session_hint === ROUTER_DECISION_SESSION_HINT.NEW) {
|
||
return null;
|
||
}
|
||
|
||
return sessionId;
|
||
}
|
||
|
||
export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) {
|
||
return {
|
||
enabled: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_ENABLED, false),
|
||
modelProviderKeyId: String(env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
|
||
model: String(env?.MEMIND_CHAT_ROUTER_MODEL ?? '').trim() || null,
|
||
modelApiType: String(env?.MEMIND_CHAT_ROUTER_MODEL_API ?? '').trim() || 'chat',
|
||
temperature: boundedNumber(env?.MEMIND_CHAT_ROUTER_TEMPERATURE, 0, { min: 0, max: 2 }),
|
||
minConfidence: boundedNumber(
|
||
env?.MEMIND_CHAT_ROUTER_MIN_CONFIDENCE,
|
||
DEFAULT_ROUTER_MIN_CONFIDENCE,
|
||
{ min: 0, max: 1 },
|
||
),
|
||
memoryResolveEnabled: envFlag(env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED, true),
|
||
memoryResolveLimit: Math.round(boundedNumber(
|
||
env?.MEMIND_CHAT_ROUTER_MEMORY_LIMIT,
|
||
DEFAULT_ROUTER_MEMORY_LIMIT,
|
||
{ min: 1, max: 50 },
|
||
)),
|
||
timeoutMs: Math.round(boundedNumber(
|
||
env?.MEMIND_CHAT_ROUTER_TIMEOUT_MS,
|
||
DEFAULT_ROUTER_TIMEOUT_MS,
|
||
{ min: 0, max: 30_000 },
|
||
)),
|
||
...overrides,
|
||
fallbackRoute: resolveRouterFallbackRoute(env, overrides),
|
||
};
|
||
}
|
||
|
||
function normalizeClassification(raw, { source, fallbackRoute = CHAT_INTENT_ROUTE.AGENT } = {}) {
|
||
const route = normalizeRoute(raw?.route) ?? fallbackRoute;
|
||
const confidenceRaw = Number(raw?.confidence);
|
||
const confidence = Number.isFinite(confidenceRaw)
|
||
? Math.min(1, Math.max(0, confidenceRaw))
|
||
: source === 'llm' ? 0.7 : 1;
|
||
const suggestedSkill = String(raw?.suggested_skill ?? raw?.suggestedSkill ?? '').trim() || null;
|
||
const agentBrief = String(raw?.agent_brief ?? raw?.agentBrief ?? '').trim();
|
||
const reason = String(raw?.reason ?? '').trim() || (source === 'llm' ? '模型路由判定' : '规则路由判定');
|
||
return {
|
||
route,
|
||
confidence,
|
||
reason,
|
||
suggestedSkill,
|
||
agentBrief,
|
||
source,
|
||
providerKeyId: raw?.providerKeyId ?? raw?.provider_key_id ?? null,
|
||
model: raw?.model ?? null,
|
||
memory: raw?.memory ?? null,
|
||
};
|
||
}
|
||
|
||
function resolveSkillPrompt(suggestedSkill, grantedSkills = [], displayText = '') {
|
||
const skillName = String(suggestedSkill ?? '').trim();
|
||
if (!skillName) return '';
|
||
if (grantedSkills.length > 0 && !grantedSkills.includes(skillName)) return '';
|
||
if (skillName === 'web' && isRealtimeInfoQuestion(displayText)) {
|
||
return buildWebNewsSkillPrompt(skillName);
|
||
}
|
||
const promptKey = SKILL_PROMPT_KEYS[skillName];
|
||
if (!promptKey) return '';
|
||
return buildChatSkillPrompt(promptKey, skillName);
|
||
}
|
||
|
||
export function buildAgentOrchestrationAgentText({
|
||
displayText,
|
||
classification,
|
||
skillPrompt = '',
|
||
}) {
|
||
const taskBody = String(displayText ?? '').trim();
|
||
const lines = [
|
||
`${AGENT_ORCHESTRATION_HEADER}以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。`,
|
||
`路由判定:${classification.reason}`,
|
||
classification.agentBrief ? `执行要点:${classification.agentBrief}` : '',
|
||
classification.suggestedSkill ? `建议 skill:${classification.suggestedSkill}` : '',
|
||
skillPrompt,
|
||
'',
|
||
'用户任务:',
|
||
taskBody,
|
||
].filter((line, index, all) => line !== '' || index === all.length - 2);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export function applyAgentOrchestrationToUserMessage(userMessage, classification, { grantedSkills = [] } = {}) {
|
||
const displayText = messageDisplayText(userMessage);
|
||
const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText);
|
||
const agentText = buildAgentOrchestrationAgentText({
|
||
displayText,
|
||
classification,
|
||
skillPrompt,
|
||
});
|
||
const content = Array.isArray(userMessage?.content)
|
||
? userMessage.content.map((item, index) => {
|
||
if (index !== 0) return item;
|
||
if (typeof item === 'string') return agentText;
|
||
if (item?.type === 'text') return { ...item, text: agentText };
|
||
return item;
|
||
})
|
||
: [{ type: 'text', text: agentText }];
|
||
const metadata = {
|
||
...(userMessage?.metadata ?? {}),
|
||
displayText: displayText || undefined,
|
||
chatIntentRoute: CHAT_INTENT_ROUTE.AGENT,
|
||
chatIntentSource: classification?.source ?? null,
|
||
userVisible: userMessage?.metadata?.userVisible ?? true,
|
||
agentVisible: userMessage?.metadata?.agentVisible ?? true,
|
||
};
|
||
return {
|
||
...userMessage,
|
||
content,
|
||
metadata,
|
||
};
|
||
}
|
||
|
||
function hasPriorAgentConversation(sessionId, sessionMessageCount) {
|
||
if (!sessionId || isDirectChatSessionId(sessionId)) return false;
|
||
if (sessionMessageCount == null) return true;
|
||
return Number(sessionMessageCount) > 0;
|
||
}
|
||
|
||
export function classifyWithRules({
|
||
text,
|
||
forceDeepReasoning = false,
|
||
toolMode = 'chat',
|
||
sessionId = null,
|
||
sessionMessageCount = null,
|
||
userMessage = null,
|
||
includeIntentPatterns = true,
|
||
llmRouterEnabled = false,
|
||
} = {}) {
|
||
const decisionContext = {
|
||
text,
|
||
forceDeepReasoning,
|
||
toolMode,
|
||
sessionId,
|
||
sessionMessageCount,
|
||
};
|
||
const normalized = String(text ?? '').trim();
|
||
if (forceDeepReasoning || toolMode !== 'chat') {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 1,
|
||
reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) {
|
||
return finalizeRouterClassification(buildSkillSelectionClassification(userMessage), decisionContext);
|
||
}
|
||
if (userMessage && !isTextOnlyUserMessage(userMessage)) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 1,
|
||
reason: '消息包含非文本内容',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (
|
||
!llmRouterEnabled &&
|
||
includeIntentPatterns &&
|
||
normalized &&
|
||
isMemoryRecallQuestion(normalized)
|
||
) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||
confidence: 0.98,
|
||
reason: '用户在询问个人记忆或历史对话',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 1,
|
||
reason: '延续已有 Agent 会话',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (
|
||
includeIntentPatterns &&
|
||
normalized &&
|
||
OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
|
||
) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.AGENT,
|
||
confidence: 0.95,
|
||
reason: '明确需要生成或发布页面/文件',
|
||
suggested_skill: 'static-page-publish',
|
||
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
|
||
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
|
||
}
|
||
if (
|
||
includeIntentPatterns &&
|
||
normalized &&
|
||
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
|
||
) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||
confidence: 0.95,
|
||
reason: '简单寒暄或连通性测试',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
if (
|
||
includeIntentPatterns &&
|
||
normalized &&
|
||
OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))
|
||
) {
|
||
return finalizeRouterClassification(normalizeClassification({
|
||
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||
confidence: 0.95,
|
||
reason: '纯文字创作或闲聊,无需执行任务',
|
||
}, { source: 'rule' }), decisionContext);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function createChatIntentRouter(options = {}) {
|
||
const {
|
||
llmProviderService,
|
||
memoryV2 = null,
|
||
env = process.env,
|
||
logger = console,
|
||
} = options;
|
||
const policy = options.policy ?? resolveChatIntentRouterPolicy({
|
||
env,
|
||
overrides: pickDefined(options, [
|
||
'enabled',
|
||
'modelProviderKeyId',
|
||
'model',
|
||
'modelApiType',
|
||
'temperature',
|
||
'minConfidence',
|
||
'memoryResolveEnabled',
|
||
'memoryResolveLimit',
|
||
'timeoutMs',
|
||
'fallbackRoute',
|
||
]),
|
||
});
|
||
|
||
function getStatus() {
|
||
return {
|
||
enabled: Boolean(policy.enabled),
|
||
modelProviderKeyId: policy.modelProviderKeyId ?? null,
|
||
model: policy.model ?? null,
|
||
modelApiType: policy.modelApiType ?? 'chat',
|
||
minConfidence: policy.minConfidence,
|
||
memoryResolveEnabled: Boolean(policy.memoryResolveEnabled),
|
||
memoryResolveLimit: policy.memoryResolveLimit,
|
||
timeoutMs: policy.timeoutMs,
|
||
fallbackRoute: policy.fallbackRoute,
|
||
normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env),
|
||
normalizedDecisionMode: resolveNormalizedRouterDecisionMode(env),
|
||
};
|
||
}
|
||
|
||
function isEnabled() {
|
||
return Boolean(policy.enabled && llmProviderService?.createChatCompletion);
|
||
}
|
||
|
||
async function resolveRouterContext({ userId, sessionId, text, forceDeepReasoning = false }) {
|
||
const intervention = resolveMemoryInterventionMode({
|
||
forceDeepReasoning,
|
||
recallQuestion: isMemoryRecallQuestion(text),
|
||
});
|
||
const limit = memoryLimitForIntervention(intervention, { context: 'router' });
|
||
if (
|
||
limit <= 0 ||
|
||
!policy.memoryResolveEnabled ||
|
||
!memoryV2?.resolve ||
|
||
!userId
|
||
) {
|
||
return buildRouterContext(null);
|
||
}
|
||
try {
|
||
const resolved = await withTimeout(
|
||
memoryV2.resolve({
|
||
userId,
|
||
sessionId,
|
||
query: text,
|
||
limit,
|
||
}),
|
||
policy.timeoutMs,
|
||
'Memory V2 router resolve',
|
||
);
|
||
return buildRouterContext(resolved);
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
`[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`,
|
||
);
|
||
return buildRouterContext({
|
||
degraded: true,
|
||
reason: 'memory_resolve_failed',
|
||
});
|
||
}
|
||
}
|
||
|
||
async function classify({
|
||
userId = null,
|
||
userMessage,
|
||
sessionId = null,
|
||
sessionMessageCount = null,
|
||
toolMode = 'chat',
|
||
forceDeepReasoning = false,
|
||
grantedSkills = [],
|
||
} = {}) {
|
||
const text = messageDisplayText(userMessage);
|
||
const decisionContext = {
|
||
text,
|
||
forceDeepReasoning,
|
||
toolMode,
|
||
sessionId,
|
||
sessionMessageCount,
|
||
};
|
||
const finalizeWithRealtimeCoercion = (classification) => {
|
||
if (!classification) return classification;
|
||
const base = { ...classification };
|
||
delete base.decision;
|
||
return finalizeRouterClassification(
|
||
coerceRealtimeWebSkill(base, text, { grantedSkills }),
|
||
decisionContext,
|
||
);
|
||
};
|
||
const ruleResult = classifyWithRules({
|
||
text,
|
||
forceDeepReasoning,
|
||
toolMode,
|
||
sessionId,
|
||
sessionMessageCount,
|
||
userMessage,
|
||
includeIntentPatterns: true,
|
||
llmRouterEnabled: Boolean(policy.enabled),
|
||
});
|
||
if (ruleResult) return finalizeWithRealtimeCoercion(ruleResult);
|
||
if (!isEnabled()) {
|
||
return finalizeWithRealtimeCoercion(normalizeClassification({
|
||
route: policy.fallbackRoute,
|
||
confidence: 0.5,
|
||
reason: '意图路由未启用,走默认通道',
|
||
}, { source: 'fallback' }));
|
||
}
|
||
|
||
const routerContext = await resolveRouterContext({
|
||
userId,
|
||
sessionId,
|
||
text,
|
||
forceDeepReasoning,
|
||
});
|
||
let completion = null;
|
||
try {
|
||
completion = await withTimeout(
|
||
llmProviderService.createChatCompletion({
|
||
providerKeyId: policy.modelProviderKeyId || undefined,
|
||
model: policy.model || undefined,
|
||
modelApiType: policy.modelApiType || undefined,
|
||
temperature: policy.temperature,
|
||
messages: [
|
||
{ role: 'system', content: buildRouterSystemPrompt(grantedSkills) },
|
||
{
|
||
role: 'user',
|
||
content: buildRouterUserPrompt({
|
||
text,
|
||
routerContext: routerContext.content,
|
||
}),
|
||
},
|
||
],
|
||
}),
|
||
policy.timeoutMs,
|
||
'Chat intent router',
|
||
);
|
||
} catch (err) {
|
||
return finalizeWithRealtimeCoercion(normalizeClassification({
|
||
route: policy.fallbackRoute,
|
||
confidence: 0,
|
||
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
|
||
memory: routerContext,
|
||
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
|
||
}
|
||
if (!completion?.ok) {
|
||
return finalizeWithRealtimeCoercion(normalizeClassification({
|
||
route: policy.fallbackRoute,
|
||
confidence: 0,
|
||
reason: completion?.message ?? '意图路由失败,走默认通道',
|
||
memory: routerContext,
|
||
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
|
||
}
|
||
|
||
const parsed = parseRouterJson(completion.reply);
|
||
if (!parsed) {
|
||
return finalizeWithRealtimeCoercion(normalizeClassification({
|
||
route: policy.fallbackRoute,
|
||
confidence: 0,
|
||
reason: '意图路由响应无法解析,走默认通道',
|
||
memory: routerContext,
|
||
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
|
||
}
|
||
|
||
const classification = finalizeWithRealtimeCoercion({
|
||
...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }),
|
||
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null,
|
||
model: completion.model ?? policy.model ?? null,
|
||
memory: routerContext,
|
||
});
|
||
if (
|
||
classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT &&
|
||
classification.confidence < policy.minConfidence
|
||
) {
|
||
return finalizeWithRealtimeCoercion(normalizeClassification({
|
||
...classification,
|
||
route: policy.fallbackRoute,
|
||
reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`,
|
||
}, { source: 'threshold', fallbackRoute: policy.fallbackRoute }));
|
||
}
|
||
return classification;
|
||
}
|
||
|
||
return {
|
||
getStatus,
|
||
isEnabled,
|
||
classify,
|
||
applyAgentOrchestration: applyAgentOrchestrationToUserMessage,
|
||
};
|
||
}
|
||
|
||
export function createManagedChatIntentRouter({
|
||
llmProviderService,
|
||
memoryV2 = null,
|
||
configService = null,
|
||
env = process.env,
|
||
logger = console,
|
||
} = {}) {
|
||
let activeRouter = null;
|
||
let activeFingerprint = null;
|
||
let activeMeta = { source: 'env', updatedAt: null, updatedBy: null, configError: null };
|
||
|
||
async function loadRouterState() {
|
||
if (!configService?.getRuntimeState) {
|
||
return {
|
||
source: 'env',
|
||
updatedAt: null,
|
||
updatedBy: null,
|
||
fingerprint: 'env-only',
|
||
effectiveEnv: { ...env },
|
||
configError: null,
|
||
};
|
||
}
|
||
try {
|
||
const state = await configService.getRuntimeState();
|
||
return {
|
||
source: state?.source ?? 'admin-db',
|
||
updatedAt: state?.updatedAt ?? null,
|
||
updatedBy: state?.updatedBy ?? null,
|
||
fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`,
|
||
effectiveEnv: { ...env, ...(state?.overrides ?? {}) },
|
||
configError: null,
|
||
};
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
`[chat-intent-router] admin config unavailable, using process env: ${err instanceof Error ? err.message : err}`,
|
||
);
|
||
return {
|
||
source: 'env-fallback',
|
||
updatedAt: null,
|
||
updatedBy: null,
|
||
fingerprint: 'env-fallback',
|
||
effectiveEnv: { ...env },
|
||
configError: err instanceof Error ? err.message : String(err),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function ensureRouter() {
|
||
const state = await loadRouterState();
|
||
if (activeRouter && activeFingerprint === state.fingerprint) {
|
||
activeMeta = state;
|
||
return activeRouter;
|
||
}
|
||
activeRouter = createChatIntentRouter({
|
||
llmProviderService,
|
||
memoryV2,
|
||
env: state.effectiveEnv,
|
||
logger,
|
||
});
|
||
activeFingerprint = state.fingerprint;
|
||
activeMeta = state;
|
||
return activeRouter;
|
||
}
|
||
|
||
return {
|
||
async getStatus() {
|
||
const router = await ensureRouter();
|
||
return {
|
||
...router.getStatus(),
|
||
configSource: activeMeta.source,
|
||
configUpdatedAt: activeMeta.updatedAt,
|
||
configUpdatedBy: activeMeta.updatedBy,
|
||
configError: activeMeta.configError,
|
||
};
|
||
},
|
||
|
||
async isEnabled() {
|
||
const router = await ensureRouter();
|
||
return router.isEnabled();
|
||
},
|
||
|
||
async classify(input = {}) {
|
||
const router = await ensureRouter();
|
||
return router.classify(input);
|
||
},
|
||
|
||
applyAgentOrchestration: applyAgentOrchestrationToUserMessage,
|
||
};
|
||
}
|