Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3df2c62c3c | |||
| cebcc85e84 | |||
| 416bf7a29a | |||
| 02fdcdedc5 | |||
| f91ee97502 |
+4
-4
@@ -525,11 +525,11 @@ MEMIND_RUNTIME_PROFILE=local
|
||||
# MEMIND_CURSOR_HELP_AGENT_BIN=/Users/john/.local/bin/agent
|
||||
# MEMIND_CURSOR_HELP_AUTO_GOOSE_REMEDIATE=1
|
||||
|
||||
# Track C: Goose 编排 + Cursor 作为 code executor(MindSpace 落盘,推荐 PoC)
|
||||
# MEMIND_CURSOR_EXECUTOR_ENABLED=1
|
||||
# Track C: TKMind 智趣(Cursor)作为 code executor — 仅对白名单用户生效(memindadm「智趣体验通道」)
|
||||
# MEMIND_CURSOR_EXECUTOR_ENABLED=1 # 服务端 Cursor CLI 基础设施开关
|
||||
# MEMIND_AIDER_SKILL_USE_CURSOR=1
|
||||
# 默认将页面生成、问卷(Page Data)、Excel 分析路由到 Cursor 执行
|
||||
# MEMIND_CURSOR_PAGE_TASKS_DEFAULT=1
|
||||
# 已废弃全员路由:页面/问卷/Excel 是否走 Cursor 由 memindadm 白名单控制,不再靠 MEMIND_CURSOR_PAGE_TASKS_DEFAULT
|
||||
# MEMIND_CURSOR_PAGE_TASKS_DEFAULT=0
|
||||
# 已废弃:不再把普通 Agent 编排全量切到 Cursor;简单对话走 DeepSeek direct_chat
|
||||
# MEMIND_CURSOR_AGENT_TASKS_DEFAULT=0
|
||||
# MEMIND_CURSOR_DEEPSEEK_FALLBACK=1
|
||||
|
||||
@@ -955,6 +955,23 @@ export function createAdminApi({
|
||||
adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||
adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||
|
||||
adminApi.get('/cursor-executor-channel/config', requireAdmin, async (_req, res) => {
|
||||
if (!wechatCursorExecutorPolicyService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
|
||||
}
|
||||
return res.json(await wechatCursorExecutorPolicyService.getAdminConfig());
|
||||
});
|
||||
|
||||
adminApi.get('/cursor-executor-channel/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!wechatCursorExecutorPolicyService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' });
|
||||
}
|
||||
return res.json(await wechatCursorExecutorPolicyService.getRuntimeState());
|
||||
});
|
||||
|
||||
adminApi.put('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||
adminApi.patch('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig);
|
||||
|
||||
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
|
||||
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
|
||||
res.json({ catalog: llmProviderService.catalog });
|
||||
|
||||
+51
-21
@@ -25,6 +25,10 @@ import { wrapRunStreamPayload, writeSseErrorAndEnd } from './sse-event-taxonomy.
|
||||
import { resolveGoalBindingForAgentRun } from './goal-run-resolve.mjs';
|
||||
import { enforcePageGenerationCursorRuntime } from './cursor-page-routing.mjs';
|
||||
import { resolvePreferredCodeExecutor } from './cursor-agent-launch.mjs';
|
||||
import {
|
||||
CURSOR_EXECUTOR_CHANNEL,
|
||||
resolveCursorChannelEligible,
|
||||
} from './wechat-cursor-executor-policy.mjs';
|
||||
|
||||
function envFlag(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
@@ -188,6 +192,7 @@ export function createPostAgentRunsHandler({
|
||||
agentRunGateway,
|
||||
mindSpaceAssetAgent = null,
|
||||
codeRunPolicyService = null,
|
||||
cursorExecutorPolicyService = null,
|
||||
goalRunService = null,
|
||||
chatIntentRouter = null,
|
||||
templateCatalogService = null,
|
||||
@@ -259,12 +264,30 @@ export function createPostAgentRunsHandler({
|
||||
let requiredExecutor = selectedSkillRuntime.requiredExecutor ?? null;
|
||||
let requiredReviewExecutor = selectedSkillRuntime.requiredReviewExecutor ?? null;
|
||||
|
||||
let cursorChannelEligible = false;
|
||||
if (cursorExecutorPolicyService?.getEffectivePolicy) {
|
||||
try {
|
||||
const cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy(
|
||||
request.currentUser.id,
|
||||
request.currentUser,
|
||||
);
|
||||
cursorChannelEligible = resolveCursorChannelEligible({
|
||||
user: request.currentUser,
|
||||
channel: CURSOR_EXECUTOR_CHANNEL.H5,
|
||||
policy: cursorPolicy,
|
||||
});
|
||||
} catch {
|
||||
cursorChannelEligible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!requiredExecutor && !requiredReviewExecutor) {
|
||||
const pageCursorRuntime = enforcePageGenerationCursorRuntime(userMessage, {
|
||||
rawToolMode,
|
||||
taskType,
|
||||
forceDeepReasoning,
|
||||
env: process.env,
|
||||
channelEligible: cursorChannelEligible,
|
||||
});
|
||||
userMessage = pageCursorRuntime.userMessage;
|
||||
rawToolMode = pageCursorRuntime.rawToolMode;
|
||||
@@ -307,29 +330,36 @@ export function createPostAgentRunsHandler({
|
||||
const policyTaskType = requiredReviewExecutor
|
||||
? 'h5_chat_code_task'
|
||||
: taskType;
|
||||
if (!codeRunPolicy.enabled) {
|
||||
response.status(403).json({ message: '代码任务灰度未开启' });
|
||||
return;
|
||||
}
|
||||
if (!codeRunPolicy.userAllowed) {
|
||||
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
|
||||
return;
|
||||
}
|
||||
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
|
||||
if (
|
||||
taskTypeAllowlist.length > 0 &&
|
||||
(
|
||||
!policyTaskType ||
|
||||
!taskTypeAllowlist
|
||||
.map((item) => String(item).toLowerCase())
|
||||
.includes(policyTaskType.toLowerCase())
|
||||
)
|
||||
) {
|
||||
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
|
||||
return;
|
||||
const isCursorChannelRun = cursorChannelEligible && (
|
||||
requiredExecutor === 'cursor'
|
||||
|| userMessage?.metadata?.memindRun?.pageCursorDefault === true
|
||||
);
|
||||
if (!isCursorChannelRun) {
|
||||
if (!codeRunPolicy.enabled) {
|
||||
response.status(403).json({ message: '代码任务灰度未开启' });
|
||||
return;
|
||||
}
|
||||
if (!codeRunPolicy.userAllowed) {
|
||||
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
|
||||
return;
|
||||
}
|
||||
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
|
||||
if (
|
||||
taskTypeAllowlist.length > 0 &&
|
||||
(
|
||||
!policyTaskType ||
|
||||
!taskTypeAllowlist
|
||||
.map((item) => String(item).toLowerCase())
|
||||
.includes(policyTaskType.toLowerCase())
|
||||
)
|
||||
) {
|
||||
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
codeRunPolicy.requireValidation
|
||||
!isCursorChannelRun
|
||||
&& codeRunPolicy.requireValidation
|
||||
&& !hasExpectedFileValidation(userMessage)
|
||||
&& !userMessage?.metadata?.memindRun?.pageCursorDefault
|
||||
&& !userMessage?.metadata?.memindRun?.cursorAgentDefault
|
||||
|
||||
@@ -1091,3 +1091,128 @@ test('POST /agent/runs materializes selected assets before creating run', async
|
||||
assetIds: ['asset-9'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('POST /agent/runs keeps non-allowlisted H5 users on existing chat flow for page tasks', async () => {
|
||||
const created = [];
|
||||
const prevCursorEnabled = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED;
|
||||
const prevUseCursor = process.env.MEMIND_AIDER_SKILL_USE_CURSOR;
|
||||
process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1';
|
||||
process.env.MEMIND_AIDER_SKILL_USE_CURSOR = '1';
|
||||
try {
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
cursorExecutorPolicyService: {
|
||||
async getEffectivePolicy(userId) {
|
||||
return {
|
||||
enabled: true,
|
||||
userAllowlist: ['other-user'],
|
||||
channelAllowlist: ['h5', 'wechat_mp'],
|
||||
intentAllowlist: ['page.generate'],
|
||||
userAllowed: userId === 'other-user',
|
||||
};
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun(userId, payload) {
|
||||
created.push({ userId, payload });
|
||||
return { id: 'run-chat-1', status: 'queued' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1', username: 'john' },
|
||||
body: {
|
||||
session_id: 'session-1',
|
||||
request_id: 'req-page-1',
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
|
||||
metadata: { displayText: '帮我写首诗,并做成页面' },
|
||||
},
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
assert.equal(res.statusCode, 202);
|
||||
assert.equal(created[0].payload.toolMode, 'chat');
|
||||
assert.equal(created[0].payload.taskType, null);
|
||||
assert.notEqual(created[0].payload.userMessage?.metadata?.memindRun?.executor, 'cursor');
|
||||
assert.notEqual(created[0].payload.userMessage?.metadata?.memindRun?.pageCursorDefault, true);
|
||||
} finally {
|
||||
if (prevCursorEnabled == null) delete process.env.MEMIND_CURSOR_EXECUTOR_ENABLED;
|
||||
else process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = prevCursorEnabled;
|
||||
if (prevUseCursor == null) delete process.env.MEMIND_AIDER_SKILL_USE_CURSOR;
|
||||
else process.env.MEMIND_AIDER_SKILL_USE_CURSOR = prevUseCursor;
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /agent/runs routes allowlisted H5 users into cursor channel for page tasks', async () => {
|
||||
const created = [];
|
||||
const prevCursorEnabled = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED;
|
||||
const prevUseCursor = process.env.MEMIND_AIDER_SKILL_USE_CURSOR;
|
||||
process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1';
|
||||
process.env.MEMIND_AIDER_SKILL_USE_CURSOR = '1';
|
||||
try {
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
cursorExecutorPolicyService: {
|
||||
async getEffectivePolicy(userId) {
|
||||
return {
|
||||
enabled: true,
|
||||
userAllowlist: ['user-1'],
|
||||
channelAllowlist: ['h5'],
|
||||
intentAllowlist: ['page.generate'],
|
||||
userAllowed: userId === 'user-1',
|
||||
};
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun(userId, payload) {
|
||||
created.push({ userId, payload });
|
||||
return { id: 'run-cursor-1', status: 'queued' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1', username: 'john' },
|
||||
body: {
|
||||
session_id: 'session-1',
|
||||
request_id: 'req-page-2',
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
|
||||
metadata: { displayText: '帮我写首诗,并做成页面' },
|
||||
},
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
assert.equal(res.statusCode, 202);
|
||||
assert.equal(created[0].payload.toolMode, 'code');
|
||||
assert.equal(created[0].payload.taskType, 'h5_chat_code_task');
|
||||
assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.executor, 'cursor');
|
||||
assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.pageCursorDefault, true);
|
||||
assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.cursorChannel, true);
|
||||
} finally {
|
||||
if (prevCursorEnabled == null) delete process.env.MEMIND_CURSOR_EXECUTOR_ENABLED;
|
||||
else process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = prevCursorEnabled;
|
||||
if (prevUseCursor == null) delete process.env.MEMIND_AIDER_SKILL_USE_CURSOR;
|
||||
else process.env.MEMIND_AIDER_SKILL_USE_CURSOR = prevUseCursor;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,8 +27,9 @@ export function resolvePreferredCodeExecutor(env = process.env) {
|
||||
}
|
||||
|
||||
export function cursorPageTasksDefaultEnabled(env = process.env) {
|
||||
// 已废弃:页面任务是否走 Cursor 由 memindadm「智趣体验通道」白名单控制,不再靠此 env 全员开启。
|
||||
if (!cursorExecutorEnabled(env)) return false;
|
||||
return envFlag(env.MEMIND_CURSOR_PAGE_TASKS_DEFAULT, true);
|
||||
return envFlag(env.MEMIND_CURSOR_PAGE_TASKS_DEFAULT, false);
|
||||
}
|
||||
|
||||
/** @deprecated 普通 Agent 编排不再默认走 Cursor;仅页面/问卷/Excel 等代码任务使用 enforcePageGenerationCursorRuntime */
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './chat-skills.mjs';
|
||||
import {
|
||||
cursorAgentTasksDefaultEnabled,
|
||||
cursorExecutorEnabled,
|
||||
cursorPageTasksDefaultEnabled,
|
||||
resolvePreferredCodeExecutor,
|
||||
} from './cursor-agent-launch.mjs';
|
||||
@@ -61,8 +62,10 @@ export function resolveCursorTaskKind(taskText, skill = '') {
|
||||
export function isPageCursorDefaultCandidate(userMessage, {
|
||||
rawToolMode = 'chat',
|
||||
env = process.env,
|
||||
channelEligible = false,
|
||||
} = {}) {
|
||||
if (!cursorPageTasksDefaultEnabled(env)) return false;
|
||||
if (!cursorExecutorEnabled(env)) return false;
|
||||
if (!channelEligible) return false;
|
||||
if (String(rawToolMode ?? '').trim().toLowerCase() === 'code') return false;
|
||||
|
||||
const skill = selectedChatSkill(userMessage);
|
||||
@@ -178,8 +181,9 @@ export function enforcePageGenerationCursorRuntime(userMessage, {
|
||||
taskType = null,
|
||||
forceDeepReasoning = false,
|
||||
env = process.env,
|
||||
channelEligible = false,
|
||||
} = {}) {
|
||||
if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env })) {
|
||||
if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env, channelEligible })) {
|
||||
return {
|
||||
userMessage,
|
||||
rawToolMode,
|
||||
@@ -216,6 +220,8 @@ export function enforcePageGenerationCursorRuntime(userMessage, {
|
||||
executor: codeExecutor,
|
||||
pageCursorDefault: true,
|
||||
cursorTaskKind: taskKind,
|
||||
cursorChannel: true,
|
||||
channel: 'h5',
|
||||
suggestedDelivery: resolveCursorSuggestedDelivery(taskKind),
|
||||
selectedChatSkill: resolveCursorTaskSkill(taskKind, runMetadata.selectedChatSkill),
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ test('page cursor default matches poem page requests', () => {
|
||||
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
|
||||
metadata: { displayText: '帮我写首诗,并做成页面', userVisible: true },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true);
|
||||
assert.equal(resolveCursorTaskKind('帮我写首诗,并做成页面'), 'page_generation');
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ test('page cursor default matches survey page-data requests', () => {
|
||||
content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }],
|
||||
metadata: { displayText: '帮我做一个调查问卷,结果存 PG' },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true);
|
||||
assert.equal(resolveCursorTaskKind('帮我做一个调查问卷,结果存 PG'), 'page_data');
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ test('page cursor default matches excel analysis requests', () => {
|
||||
content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }],
|
||||
metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true);
|
||||
assert.equal(resolveCursorTaskKind('帮我分析这个 Excel 表格的数据趋势'), 'excel_analysis');
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test('page cursor default skips generic empty page requests', () => {
|
||||
content: [{ type: 'text', text: '帮我做一个页面吧' }],
|
||||
metadata: { displayText: '帮我做一个页面吧' },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false);
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), false);
|
||||
});
|
||||
|
||||
test('page cursor default skips page-data dev repair requests', () => {
|
||||
@@ -57,7 +57,7 @@ test('page cursor default skips page-data dev repair requests', () => {
|
||||
content: [{ type: 'text', text: '问卷页面提交失败,帮我排查 Page Data 绑定' }],
|
||||
metadata: { displayText: '问卷页面提交失败,帮我排查 Page Data 绑定' },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false);
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), false);
|
||||
});
|
||||
|
||||
test('enforcePageGenerationCursorRuntime switches page generation to code cursor executor', () => {
|
||||
@@ -66,7 +66,7 @@ test('enforcePageGenerationCursorRuntime switches page generation to code cursor
|
||||
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
|
||||
metadata: { displayText: '帮我写首诗,并做成页面' },
|
||||
};
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true });
|
||||
assert.equal(enforced.rawToolMode, 'code');
|
||||
assert.equal(enforced.taskType, 'h5_chat_code_task');
|
||||
assert.equal(enforced.requiredExecutor, 'cursor');
|
||||
@@ -86,7 +86,7 @@ test('enforcePageGenerationCursorRuntime switches survey tasks to cursor page-da
|
||||
content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }],
|
||||
metadata: { displayText: '帮我做一个调查问卷,结果存 PG' },
|
||||
};
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true });
|
||||
assert.equal(enforced.rawToolMode, 'code');
|
||||
assert.equal(enforced.requiredExecutor, 'cursor');
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'page_data');
|
||||
@@ -102,7 +102,7 @@ test('enforcePageGenerationCursorRuntime switches excel analysis to cursor flow'
|
||||
content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }],
|
||||
metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' },
|
||||
};
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
|
||||
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true });
|
||||
assert.equal(enforced.rawToolMode, 'code');
|
||||
assert.equal(enforced.requiredExecutor, 'cursor');
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'excel_analysis');
|
||||
@@ -110,6 +110,15 @@ test('enforcePageGenerationCursorRuntime switches excel analysis to cursor flow'
|
||||
assert.match(enforced.userMessage.content[0].text, /Excel analysis task via TKMind 智趣 executor/);
|
||||
});
|
||||
|
||||
test('page cursor default is disabled without channelEligible even when executor env is on', () => {
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
|
||||
metadata: { displayText: '帮我写首诗,并做成页面' },
|
||||
};
|
||||
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: false }), false);
|
||||
});
|
||||
|
||||
test('page cursor default is disabled without env flag', () => {
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
# 聊天 ↔ 任务执行意图层评估与改造方案
|
||||
|
||||
日期: 2026-08-28
|
||||
|
||||
状态: 设计评估(只读核查完成,未改动任何代码)
|
||||
|
||||
适用范围:
|
||||
|
||||
- H5 聊天主链路的路由层:`chat-intent-router`、`agent-run-gateway`、`direct-chat-service`。
|
||||
- direct chat 与 Goose agent 之间的会话身份、上下文传递与升级路径。
|
||||
- 「用户正常聊天 → 系统识别意图 → 触发任务执行」这一交互闭环。
|
||||
|
||||
非目标:
|
||||
|
||||
- 不重写 Goose,不改 goosed 协议。
|
||||
- 不引入新的任务存储模型或新框架。
|
||||
- 不改变现有发布闸门与回归守卫规则。
|
||||
- 不重复 [H5 Session 架构方案](../h5-session-architecture-20260706.md) 中已落地的 session broker / SSE replay 能力。
|
||||
|
||||
## 0. 结论
|
||||
|
||||
被评估的原方案(引入 Task Resolver + Task State Machine + Working Memory 的「Task Runtime」)**方向正确但不是最优解**:它假设「意图飘忽」源于缺少任务控制层,而代码核查显示,真实根因是路由层的三个具体缺陷。
|
||||
|
||||
核查确认的三个根因:
|
||||
|
||||
| 编号 | 问题 | 性质 |
|
||||
|---|---|---|
|
||||
| R1 | direct chat 的历史不进入 Goose 执行输入 | 上下文单向丢失 |
|
||||
| R2 | 纯聊天会话中 LLM 意图识别**永不启动** | 吸收态死区 |
|
||||
| R3 | 兜底安全网对 router 判定的 direct_chat 主动放行,且 prompt 指示模型谎报「已交由后台」 | 误判被放大并对用户不可见 |
|
||||
|
||||
其中 **R2 是决定性的**:`MEMIND_CHAT_LLM_ROUTER_ENABLED=1` 在 103 生产已开启,但在纯聊天会话里那个 LLM 根本不会被调用。语义意图识别目前实际由正则承担。
|
||||
|
||||
**Task Runtime 无法解决 R1–R3 中的任何一个。** 因此本文提出替代路线:先修复路由层缺陷,让意图识别真正生效;Task 抽象留待有数据支撑后再增量引入。
|
||||
|
||||
### 0.1 与相关文档的关系
|
||||
|
||||
| 文档 | 关系 |
|
||||
|---|---|
|
||||
| [H5 Session 架构方案](../h5-session-architecture-20260706.md) | 本文在其 control plane / execution runtime 划分之上,处理 chat↔agent 的路由与上下文缺口 |
|
||||
| [Intent Transaction Layer 设计](../intent-transaction-layer-design.md) | ITL 管**有副作用的创建动作**(提醒/定时)的 Draft→Confirm→Commit;本文管**多轮对话中的任务识别与执行移交**,两者并列,不应合并进同一分类器 |
|
||||
| [SSE 断线续播守卫](../regression-guards/h5-session-stream-replay.md) | 本文 P2 触及 `agent-run-gateway` session 路径,改动须重跑该守卫 |
|
||||
| [Orchestrator 边界](./memind-orchestrator-boundary.md) | 本文不改变 Portal = control plane、Goose/Orchestrator = execution 的划分 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 被评估的原方案
|
||||
|
||||
原方案主张把架构从 `H5 → Goose → LLM → Tools` 升级为在 H5 与 Goose 之间插入 Intent Gateway / Task Controller,核心为三件事:
|
||||
|
||||
1. **Task Resolver** — 每轮先判断本句与当前任务的关系(SAME / MODIFY / NEW / CANCEL / AMBIGUOUS),用小模型输出严格 JSON。
|
||||
2. **Task State Machine** — `NEW → UNDERSTANDING → PLANNING → EXECUTING → WAITING_USER → COMPLETED`,状态由程序控制,LLM 无权变更。
|
||||
3. **Working Memory** — 结构化的 goal / constraints / decisions / pending_questions,作为权威状态;conversation history 降级为「证据」。
|
||||
|
||||
配套还包括 Intent Lock(相似度阈值 0.8 / 0.5)与 Goal → Task → Step 树。
|
||||
|
||||
## 2. 对原方案的评估
|
||||
|
||||
### 2.1 未量化根因就动架构
|
||||
|
||||
整个方案建立在「意图飘忽」的主观描述上。没有漂移基线指标,就无法判断根因,也无法证明改造有效。这是最需要先补的一环。
|
||||
|
||||
### 2.2 Task Resolver 修不了已确认的根因
|
||||
|
||||
即便 relation 判定 100% 准确、task state 完美维护:
|
||||
|
||||
- R1 仍在:Goose 依旧收不到 direct 轮次的原文。
|
||||
- R2 仍在:Resolver 本身要在路由之后才有意义,而路由在聊天会话里已提前锁死。
|
||||
|
||||
Resolver 输出的结构化摘要反而会成为唯一信息源,正是 2.5 所述的权威倒置。
|
||||
|
||||
### 2.3 每轮新增阻塞 LLM 调用
|
||||
|
||||
Resolver 在关键路径上增加一次调用,带来延迟与新失败模式。配合 Intent Lock 后,分类错误会被**放大**:原本错一轮即自愈,改为锁定 N 轮,用户需显式挣脱。「偶发漂移」被换成「确定性卡死」,后者体感更差。
|
||||
|
||||
### 2.4 硬 FSM 过度设定
|
||||
|
||||
`UNDERSTANDING`、`PLANNING` 是不可观测的内部状态,外部无法可靠判定进入/退出。真实对话不服从该链(随时插话、回退、并行两件事),最终会退化为大量转移补丁。GPT 系产品均未采用显式 task FSM。
|
||||
|
||||
### 2.5 Working Memory 权威化是危险倒置
|
||||
|
||||
一旦 WM 权威且不再发送完整历史,WM 漏写某个约束时模型**无法从原文恢复**。GPT 的做法相反:完整上下文是权威,结构化记忆只是 hint,抽取错了仍可被原文纠正。
|
||||
|
||||
### 2.6 Intent Lock 阈值是魔数陷阱
|
||||
|
||||
`>0.8 / 0.5~0.8 / <0.5` 的阈值不可跨 domain 迁移。原方案自身已给出反例(「换个话题,帮我看看 Mac」embedding 仍高相似)。叠加 rules 与小模型兜底后,embedding 层已无净贡献,只剩调参负担。
|
||||
|
||||
### 2.7 会成为第四套任务模型
|
||||
|
||||
现状已有 `h5_agent_runs`、`h5_goal_checkpoints`、`h5_unified_tasks`、orchestrator run 四套并存。`AGENTS.md` 已点名 gateway 边界偏厚。Goal→Task→Step 树依赖高质量任务分解,属于过早抽象。
|
||||
|
||||
## 3. GPT 的实际做法(对照)
|
||||
|
||||
| 维度 | GPT 的做法 |
|
||||
|---|---|
|
||||
| 任务状态 | 不维护外部 FSM,整个 thread 即状态 |
|
||||
| 轮次延续 | 无 relation classifier,模型看到完整线程自然延续 |
|
||||
| 记忆 | 扁平事实注入,不是状态机 |
|
||||
| 工具选择 | **模型自选**,靠 tool description 质量,不靠外部 router |
|
||||
| 显式任务对象 | 只给 Deep Research / Tasks / Agent mode,且**用户显式创建**,不靠推断 |
|
||||
| 路由 | 路由的是**算力**(fast vs thinking),不是**任务身份** |
|
||||
| 上下文压缩 | 滚动摘要,不是手写 schema |
|
||||
|
||||
**核心推论:GPT 的稳定性来自「不切断上下文」+「不每轮重判」,而非「更强的控制层」。** 原方案的两个主要动作恰好反向:切断上下文(WM 替代历史)+ 加强重判(每轮 Resolver)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码核查结论
|
||||
|
||||
以下均为 `main`(`416bf7a2`)只读核查,附 file:line 证据。
|
||||
|
||||
### 4.1 R1:上下文单向丢失
|
||||
|
||||
割裂是**不对称**的:
|
||||
|
||||
| 翻转方向 | Goose / LLM 能否看到上一轮 | 性质 |
|
||||
|---|---|---|
|
||||
| agent → direct | 通常能(经 snapshot 回写) | 有竞态窗口,且仅 12 条 |
|
||||
| direct → agent | **确定看不到** | 真实 gap |
|
||||
|
||||
`h5direct_` 会话升级时,旧 transcript 只写入 DB:
|
||||
|
||||
- `agent-run-gateway.mjs:2071-2089` — `persistSessionTranscriptMessages` 写 `h5_conversation_messages`
|
||||
- `agent-run-gateway.mjs:2094-2105` — 同 Goose session 上的 direct 轮次同理
|
||||
|
||||
而 `h5_conversation_messages` 仅用于 GET 展示修复(`conversation-repair.mjs:243-251`),**不进入 Goose 推理输入**。Portal 每轮只 submit 当前一条消息(`tkmind-proxy.mjs:2095-2098`),Goose 依赖自己服务端的会话历史——但 direct 轮次从未写入 Goose。
|
||||
|
||||
**关键发现:修复所需机制已存在且久经验证。** `buildFreshSessionContext`(`agent-run-gateway.mjs:202-224`,16 条 / 12000 字符)与 `appendFreshSessionContext` 已在三处使用:
|
||||
|
||||
| 调用点 | 场景 |
|
||||
|---|---|
|
||||
| `agent-run-gateway.mjs:2117` | session compaction 后 |
|
||||
| `agent-run-gateway.mjs:2157` | session rotation 后 |
|
||||
| `agent-run-gateway.mjs:2268` | poisoned session 恢复 |
|
||||
|
||||
**唯独没有接到 direct → agent 升级路径。** 这不是缺架构,是一条已验证能力没接上入口。
|
||||
|
||||
### 4.2 R1 衍生:四条路径的历史窗口口径不一
|
||||
|
||||
| 路径 | 窗口 | 位置 |
|
||||
|---|---|---|
|
||||
| direct chat LLM | 12 条 | `direct-chat-service.mjs:145` |
|
||||
| router 分类 | 8 轮 / 4000 字符,且**跳过 `h5direct_*`** | `chat-intent-router.mjs:724` |
|
||||
| session rotation | 16 条 / 12000 字符 | `agent-run-gateway.mjs:202` |
|
||||
| Goose agent | 服务端全量 | — |
|
||||
|
||||
router 明确跳过 `h5direct_*` 快照,意味着**上一轮若是 direct chat,router 做路由判断时看不到它**。这直接解释短消息漂移。
|
||||
|
||||
### 4.3 R2:意图识别吸收态(决定性问题)
|
||||
|
||||
路由为「规则优先,规则返回 null 才进 LLM」,而规则兜底硬判 direct_chat:
|
||||
|
||||
- `chat-intent-router.mjs:1366-1370` — 兜底 `direct_chat`,confidence `0.72`
|
||||
|
||||
唯一能把控制权交给 LLM router 的开关是 `shouldDeferActiveTaskRoutingToLlm`,其第一行即要求存在活跃 agent 任务:
|
||||
|
||||
- `chat-intent-router.mjs:365` — `if (!activeTaskContext?.hasRecentAgentTask) return false;`
|
||||
|
||||
而 `activeTaskContext` 在聊天会话中恒为 null,两道门都关着:
|
||||
|
||||
- `agent-run-gateway.mjs:646-649` — `isDirectChatSessionId(sessionId)` 直接返回 null
|
||||
- `agent-run-gateway.mjs:675-679` — 上一轮非 agent route 则返回 null
|
||||
|
||||
形成闭环:
|
||||
|
||||
```text
|
||||
新对话
|
||||
↓ 无活跃 agent 任务
|
||||
规则兜底 → direct_chat
|
||||
↓
|
||||
创建 h5direct_ 会话
|
||||
↓ isDirectChatSessionId → activeTaskContext = null
|
||||
shouldDeferActiveTaskRoutingToLlm = false
|
||||
↓
|
||||
规则兜底 → direct_chat
|
||||
↓
|
||||
(永远循环)
|
||||
```
|
||||
|
||||
**这是一个吸收态。** 103 生产虽已设置 `MEMIND_CHAT_LLM_ROUTER_ENABLED=1` / `SHADOW=0`,但在纯聊天会话中该 LLM 不会被调用。**语义意图识别实际由正则承担**;唯一逃逸口是命中 `OBVIOUS_AGENT_PATTERNS` 等硬模式,或用户手动开启深度推理。
|
||||
|
||||
### 4.4 R3:安全网失效与谎报
|
||||
|
||||
`direct-chat-service` 的兜底检查对 router 判定的 direct_chat 主动放行:
|
||||
|
||||
- `direct-chat-service.mjs:199-201` — `routingDecision !== 'direct_chat' && requiresTaskExecution(...)`
|
||||
|
||||
即:误判概率最高的来源(0.72 兜底)享受最高信任级别。
|
||||
|
||||
同时 system prompt 指示模型谎报:
|
||||
|
||||
- `direct-chat-service.mjs:141` — 「简要说明**已交由后台任务处理**或请用户确认具体任务」
|
||||
|
||||
direct chat 无任何工具、无任何升级路径,实际什么都没排队。用户以为任务在跑,等待无果后回来追问,而追问那一轮因 4.3 的吸收态大概率仍是 direct_chat。**这条链路能完整解释「意图飘忽」的用户体感。**
|
||||
|
||||
### 4.5 交互质量评分
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|---|---|---|
|
||||
| 用户可以正常聊天 | 良好 | 规则 fast-path,延迟低,有记忆 |
|
||||
| 聊天流畅度 | 中等 | direct chat 非流式(`llm-providers.mjs:1670` `stream:false`),前端 600ms 轮询 |
|
||||
| 聊天中识别意图 | **差** | 语义识别不启动,只剩正则 |
|
||||
| 识别后触发执行 | **差** | 同轮无法升级;提示语谎报 |
|
||||
| 误判后恢复 | **差** | 「不对,你去执行」在 `h5direct_` 会话不触发延续 |
|
||||
| Agent 执行体验 | 良好 | 真 SSE、工具卡片、loading tips 齐全 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 改造方案:Context Stability First
|
||||
|
||||
按 ROI 排序。P0–P1 不增加任何每轮 LLM 调用。
|
||||
|
||||
> **实施路径见 [§11 最低影响实施方案](#11-最低影响实施方案推荐落地路径)。** 本章描述目标形态,§11 描述推荐的落地顺序与降风险取舍;两者冲突时以 §11 为准。P2 的实现方式在 §11.4 被修订。
|
||||
|
||||
### P0 · 观测先行(零生产风险)
|
||||
|
||||
先把「漂移」变成可测数字。**数据源已存在,无需新埋点**:
|
||||
|
||||
| 指标 | 数据源 |
|
||||
|---|---|
|
||||
| 0.72 兜底占比 | `intent_routed` 事件的 `reason` / `confidence` |
|
||||
| direct→agent 升级频率 | `direct_session_escalated_to_deep_reasoning` |
|
||||
| 丢上下文的 agent 回答量 | `portal_direct_transcript_persisted` |
|
||||
| direct chat 放行的任务类请求 | `direct_chat_skipped` / `direct_chat_completed` |
|
||||
| route flip / skill flip 率 | 同 session 内 `intent_routed` 序列 |
|
||||
| 短消息路由分布 | ≤6 字消息的 `intent_routed` |
|
||||
|
||||
外加人工标注 100 条真实漂移 case 做归因。**没有 P0,后续每层改动都无法证伪。**
|
||||
|
||||
### P1 · 打通 direct → agent 上下文
|
||||
|
||||
复用已验证能力,不引入新抽象:
|
||||
|
||||
| 步骤 | 内容 | 风险 |
|
||||
|---|---|---|
|
||||
| P1.1 | 升级路径接入 `appendFreshSessionContext` | 低,复用 rotation 同款函数 |
|
||||
| P1.2 | router transcript 不再跳过 `h5direct_*` 快照 | 低 |
|
||||
| P1.3 | 统一 12 / 8 / 16 三个窗口口径 | 低,需回归 |
|
||||
|
||||
### P2 · 打破吸收态:让模型自己声明要执行
|
||||
|
||||
**这是解 R2 + R3 的核心动作,也是与 GPT 做法一致的方向。**
|
||||
|
||||
现状是「回合开始前用外部分类器猜」。改为「让回答问题的那个模型自己决定调工具」:给 direct chat **一个**工具 `execute_task`。
|
||||
|
||||
- direct chat 保留纯文本快速回答能力,闲聊体验不变。
|
||||
- 当它判断需要执行时,不再输出「已交由后台处理」这句谎话,而是发出一次结构化调用。
|
||||
- gateway 收到该调用 → 走升级路径 → 真正交给 Goose(并经 P1 带上完整聊天历史)。
|
||||
|
||||
收益:
|
||||
|
||||
1. 意图识别由**掌握完整上下文**的那个模型做,信息量远超前置分类器。
|
||||
2. **不新增每轮 LLM 调用** —— 复用本来就要发的那次 completion。
|
||||
3. **不依赖 `activeTaskContext`** —— 彻底绕开吸收态。
|
||||
4. prompt 不再需要撒谎,「已交由后台」变成真的。
|
||||
5. 与「工具由模型选,不靠外部 router」的判断一致。
|
||||
|
||||
同时移除 `direct-chat-service.mjs:199` 对 `direct_chat` 的无条件放行,让 `requiresTaskExecution` 在所有路由下都生效,作为第二道网。
|
||||
|
||||
### P3 · 非对称延续策略
|
||||
|
||||
**注意:不可照搬对称的「默认延续」,那会让 R2 更严重**(聊天会话里默认延续 = 永远聊下去)。正确形态是非对称的:
|
||||
|
||||
| 方向 | 策略 | 理由 |
|
||||
|---|---|---|
|
||||
| agent → agent | 强延续 | 防止任务执行中途掉回聊天 |
|
||||
| chat → chat | **不延续** | 必须每轮保持对升级信号敏感 |
|
||||
| chat → agent | 低门槛 | 当前是死区,需主动打开 |
|
||||
| agent → chat | 需明确信号 | 防止任务被闲聊打断 |
|
||||
|
||||
同时把 `resolveActiveTaskContext` 对 `h5direct_` 的硬返回 null 放宽,使「不对,你去执行」类纠正在聊天会话中也能生效。
|
||||
|
||||
### P4 · Conversation State Card(轻量、非权威、异步)
|
||||
|
||||
Finish 后**异步**抽取,下轮注入 prompt:
|
||||
|
||||
```text
|
||||
【当前上下文】
|
||||
目标:选购 25L 车载冰箱
|
||||
已知约束:MPV / 宽度≤60cm / 压缩机制冷
|
||||
候选:英得尔 KD25
|
||||
```
|
||||
|
||||
与原方案 Working Memory 的四个关键差异:
|
||||
|
||||
- **非权威**:历史仍照常发送,Card 丢失不影响正确性。
|
||||
- **异步**:不在请求关键路径,不增加延迟。
|
||||
- **扁平**:仅 facts + constraints 两个列表,无 FSM、无 step 树。
|
||||
- **可降级**:抽取失败则不注入,静默退化。
|
||||
|
||||
形态上可与既有 `goal-run-context.mjs:10` 的 `buildGoalContextEnvelope` 并列注入。
|
||||
|
||||
### P5 · 显式长任务对象(复用 Goal Run)
|
||||
|
||||
仅**用户显式发起**的长任务才创建 task 对象,且用户可见可控。不推断、不自动建、不新建表——直接复用 `goal-run-service` + `h5_goal_checkpoints`。
|
||||
|
||||
### P6 · 聊天流式输出(体验项,可并行)
|
||||
|
||||
direct chat 当前 `stream:false` + 前端 600ms 轮询,长回答等待感明显差于 agent 路径。改为 token 流式可显著提升闲聊体验,与路由问题正交,可独立排期。
|
||||
|
||||
---
|
||||
|
||||
## 6. 明确不做
|
||||
|
||||
- 不做每轮阻塞式 Task Resolver LLM 调用。
|
||||
- 不做 `UNDERSTANDING` / `PLANNING` 等不可观测状态。
|
||||
- 不做 embedding 相似度阈值锁。
|
||||
- 不把 conversation 降级为「证据」。
|
||||
- 不新建第四套 task 存储模型。
|
||||
|
||||
## 7. 两案对比
|
||||
|
||||
| 维度 | 原方案(Task Runtime) | 本方案(Context Stability First) |
|
||||
|---|---|---|
|
||||
| 每轮额外 LLM 调用 | 1 次(阻塞) | 0 次 |
|
||||
| 新增存储 | Task Runtime 表 + WM | 无(P4 可复用 snapshot 扩展字段) |
|
||||
| 能否解 R1 | 否 | 是(P1) |
|
||||
| 能否解 R2 | 否 | 是(P2) |
|
||||
| 能否解 R3 | 否 | 是(P2) |
|
||||
| 错误自愈 | 差(lock 放大错误) | 好(下轮即恢复) |
|
||||
| 上下文权威源 | Working Memory | 完整会话历史 |
|
||||
| 与 GPT 做法 | 相反 | 一致 |
|
||||
| 可证伪 | 无基线指标 | P0 先建基线 |
|
||||
| 首个收益出现 | 三模块全做完之后 | P1 完成即可见 |
|
||||
|
||||
## 8. 落地顺序与验证
|
||||
|
||||
| 阶段 | 内容 | 验证 |
|
||||
|---|---|---|
|
||||
| P0 | 观测基线 | 只读,无需回归 |
|
||||
| P1 | 上下文打通 | `npm run verify:h5-session-patches` |
|
||||
| P2 | `execute_task` 升级机制 | 新增 direct→agent 升级用例;`verify:h5-session-patches` |
|
||||
| P3 | 非对称延续 | `chat-intent-router.test.mjs` 扩充;短消息路由用例 |
|
||||
| P4 | State Card | 异步路径,失败降级用例 |
|
||||
| P5 | Goal Run 归并 | 灰度开关内验证 |
|
||||
| P6 | 流式输出 | 前端 SSE 用例 |
|
||||
|
||||
每阶段独立灰度、独立回归,符合 `AGENTS.md` 的单分支闭环与发布闸门要求。原方案「三模块一起上」在该治理下难以安全交付。
|
||||
|
||||
## 9. 演进留口
|
||||
|
||||
若 P0 归因数据显示漂移主因**确实**是「多步任务状态丢失」而非上述 R1–R3,再引入原方案的 Task State 抽象。届时它将是**有证据支撑的增量**,而非先建大厦再找用途。
|
||||
|
||||
原方案的 Goal → Task → Step 抽象本身没有错,错的是它的**引入时机与权威地位**。
|
||||
|
||||
## 10. 证据索引
|
||||
|
||||
| 结论 | 位置 |
|
||||
|---|---|
|
||||
| 规则兜底硬判 direct_chat | `chat-intent-router.mjs:1366-1370` |
|
||||
| LLM router 唯一入口需活跃任务 | `chat-intent-router.mjs:359-366` |
|
||||
| `h5direct_` 会话无 activeTaskContext | `agent-run-gateway.mjs:646-649` |
|
||||
| 上一轮非 agent 则无 activeTaskContext | `agent-run-gateway.mjs:675-679` |
|
||||
| 升级仅持久化到 DB | `agent-run-gateway.mjs:2071-2089`、`2094-2105` |
|
||||
| DB 仅用于展示修复 | `conversation-repair.mjs:243-251` |
|
||||
| Goose 只收当前消息 | `tkmind-proxy.mjs:2095-2098` |
|
||||
| 历史注入能力已存在 | `agent-run-gateway.mjs:202-224`、调用点 `2117` / `2157` / `2268` |
|
||||
| 安全网对 direct_chat 放行 | `direct-chat-service.mjs:199-201` |
|
||||
| prompt 指示谎报已交后台 | `direct-chat-service.mjs:141` |
|
||||
| direct chat 非流式 | `llm-providers.mjs:1670` |
|
||||
| direct chat 历史窗口 12 条 | `direct-chat-service.mjs:145` |
|
||||
| router 跳过 h5direct 快照 | `agent-run-gateway.mjs:705-706` |
|
||||
| 规则命中即短路,LLM 拿不到控制权 | `chat-intent-router.mjs:1737` |
|
||||
| shadow 模式可零行为变更观测 | `chat-intent-router.mjs:1786-1809` |
|
||||
| LLM 覆盖需过 minConfidence | `chat-intent-router.mjs:1812-1824` |
|
||||
| router 对非召回问题不调 Memory V2 | `memory-intervention.mjs:20-22` + `chat-intent-router.mjs:1522-1528` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 最低影响实施方案(推荐落地路径)
|
||||
|
||||
§5 描述目标形态,本章描述**怎么落地才能把回归风险压到最低**。核心思路:不一次做完 P0–P6,只修已证实的缺口,每步单独灰度、单独回滚、不碰 Memory V2 写链。
|
||||
|
||||
### 11.1 六条硬约束(全程遵守)
|
||||
|
||||
| # | 约束 | 目的 |
|
||||
|---|---|---|
|
||||
| C1 | Memory V2 只读,不改 `write` / `compact` / 候选 / lifecycle | 避免候选提取污染 |
|
||||
| C2 | 记忆召回问题永远走 direct_chat(保留 `chat-intent-router.mjs:1237` 硬规则) | 不碰已验证的 recall 链路 |
|
||||
| C3 | 不收紧 `direct-chat-service.mjs:199` 对 `direct_chat` 的豁免 | 避免闲聊被大批踢进 agent |
|
||||
| C4 | State Card 不进 orchestration、不进 `remember-recent` | 避免 `[Memory Context]` 自我复制 |
|
||||
| C5 | 每步独立 feature flag + 可观测事件 | 可灰度、可回滚 |
|
||||
| C6 | 升级轮次的 memory resolve 使用**旧** sessionId | 避免 episodic 线索断档 |
|
||||
|
||||
### 11.2 阶段 A · 观测(零改动)
|
||||
|
||||
对应 §5 的 P0。只读统计,不改任何路由逻辑。
|
||||
|
||||
关键指标:0.72 兜底占比、`direct_session_escalated_to_deep_reasoning` 频率、升级后 `agent_memory_resolved.injectionEnabled=false` 占比、memory recall 是否 100% direct_chat。
|
||||
|
||||
**没有阶段 A,后续所有改动都无法证伪。**
|
||||
|
||||
### 11.3 阶段 B · 只补上下文
|
||||
|
||||
对应 §5 的 P1,但**只做 P1.1**,暂不做 P1.2 / P1.3。
|
||||
|
||||
只改一件事:direct→agent 升级时,把 snapshot 历史经 `appendFreshSessionContext` 注入 Goose 输入。
|
||||
|
||||
刻意不做:不改 router 兜底、不改 `canHandle`、不改 `preferDirectChat`、不碰 Memory V2 resolve、不引入 `execute_task`。
|
||||
|
||||
Memory V2 兼容(C6):
|
||||
|
||||
```text
|
||||
升级后 agent 首轮 resolve 应使用 escalatedDirectSessionId,
|
||||
而非新建的 Goose sessionId,直到确认 episodic 为 user-scoped。
|
||||
```
|
||||
|
||||
Flag:`MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED`(默认 0)+ canary 名单。
|
||||
降级:flag 关或注入失败 → 完全退回现有行为。
|
||||
|
||||
预期影响:闲聊无变化;Memory V2 无变化;升级后 agent 不再「失忆」;仅升级轮次略增 token。
|
||||
|
||||
### 11.4 阶段 C · 打开语义识别(对 §5 P2 的修订)
|
||||
|
||||
**这是本章对原方案改动最大的一节。**
|
||||
|
||||
#### 11.4.1 为什么不用「更多升级短语 + 深度推理开关」
|
||||
|
||||
一个直觉上更安全的做法是:不动路由,只扩充 `OBVIOUS_AGENT_PATTERNS`,并引导用户手动开深度推理。
|
||||
|
||||
**否决理由:** §4.3 论证的 R2 是「语义识别不启动,只剩正则」,而该做法是**再加正则**,并把判断责任推给用户。风险确实最低,但它不修复 R2,只是把死区包装得更礼貌。产品诉求「聊天中自动识别意图并执行」仍然落空。
|
||||
|
||||
保留价值:作为**逃逸通道**而非主机制。
|
||||
|
||||
#### 11.4.2 为什么暂缓 `execute_task`
|
||||
|
||||
§5 P2 提出给 direct chat 一个 `execute_task` 工具。方向正确,但当前 `llm-providers.mjs` 的 `createChatCompletion` **不支持 tools / tool_calls**,落地需同时改:direct chat 的 function calling、同轮切 agent 的状态机、run 事件与 UI 时序、sessionId 切换、计费与 Finish 对齐。
|
||||
|
||||
风险集中且互相耦合,不适合作为第一刀。**暂缓,不否决。**
|
||||
|
||||
#### 11.4.3 采用:让已有 LLM router 在聊天会话可达
|
||||
|
||||
代码核查显示,语义识别失效的原因是**一行短路**:
|
||||
|
||||
```text
|
||||
chat-intent-router.mjs:1737
|
||||
if (ruleResult) return finalizeWithCoercion(ruleResult);
|
||||
```
|
||||
|
||||
规则一旦返回结果,LLM 分支永远拿不到控制权;而规则兜底恒返回 `direct_chat`(`:1366-1370`)。
|
||||
|
||||
因此最小改动是:**让 0.72 兜底在「歧义闸门」通过时返回 `null`**,把控制权交给已经部署好的 LLM router。
|
||||
|
||||
歧义闸门复用既有纯函数,不新增判断逻辑:
|
||||
|
||||
```text
|
||||
通过闸门 = 非 memory recall
|
||||
且 非 FAQ / 寒暄 / 纯文字创作(isExplicitDirectChatOnlyText 已覆盖)
|
||||
且 文本长度达阈值
|
||||
```
|
||||
|
||||
**这样做的优势:**
|
||||
|
||||
1. **零新基础设施** —— shadow 模式、canary 名单、`minConfidence` 覆盖闸门均已就位。
|
||||
2. **可零行为变更观测** —— `policy.shadowMode` 下只记录 `wouldChangeRoute`,路由完全不变(`:1786-1809`)。这同时补齐了阶段 A 缺失的「LLM 会怎么判」这一维数据。
|
||||
3. **覆盖有闸门** —— LLM 置信度低于 `minConfidence`(生产 0.65)时自动退回 baseline(`:1812-1824`)。
|
||||
4. **不增加 Memory V2 负载** —— router 对非召回问题 `resolveMemoryInterventionMode` 返回 `SKIP`,`limit<=0` 时直接返回空 context,不调用 `memoryV2.resolve`。
|
||||
|
||||
#### 11.4.4 三步灰度
|
||||
|
||||
| 步骤 | 配置 | 行为 | 判据 |
|
||||
|---|---|---|---|
|
||||
| C-1 | 闸门开 + `MEMIND_CHAT_LLM_ROUTER_SHADOW=1` | **路由不变**,仅记录 | 统计 `wouldChangeRoute` 比例 |
|
||||
| C-2 | `SHADOW=0` + canary 名单 | 小范围生效 | agent 触发率增幅、误判反馈 |
|
||||
| C-3 | 移除 canary | 全量 | 同上 + 成本曲线 |
|
||||
|
||||
任一步指标超阈值即回退上一步。新增 flag:`MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED`(默认 0)。
|
||||
|
||||
#### 11.4.5 成本与延迟
|
||||
|
||||
这是本阶段**唯一真实代价**:通过闸门的聊天轮次会多一次小模型 router 调用(生产为 `deepseek-chat`),在 direct chat 应答前引入约数百毫秒。
|
||||
|
||||
缓解:歧义闸门收窄触发面;沿用既有 `policy.timeoutMs` 超时降级;C-1 阶段先用 shadow 数据估算实际触发比例再决定是否推进。
|
||||
|
||||
#### 11.4.6 同步做:去掉谎报
|
||||
|
||||
独立于路由改动,属纯文案修改,零回归面:
|
||||
|
||||
```text
|
||||
direct-chat-service.mjs:141
|
||||
「简要说明已交由后台任务处理」
|
||||
→ 明确说明当前只能文字讨论,并给出可用的升级方式
|
||||
```
|
||||
|
||||
阶段 C 生效后,该文案应再次收敛——升级由系统完成时,不应继续提示用户手动操作。
|
||||
|
||||
### 11.5 阶段 D · 可选纠偏
|
||||
|
||||
仅当阶段 A/C 数据显示「短句纠偏失败」占比超过阈值时才做。
|
||||
|
||||
范围收窄为:session 为 `h5direct_` **且** 上一轮 `direct_chat_completed` **且** 命中 `AGENT_TASK_FOLLOWUP_PATTERNS` → 强制 agent,并复用阶段 B 的上下文注入。
|
||||
|
||||
不做:全面放宽 `resolveActiveTaskContext` 对 `h5direct_` 的 null 返回;不做 chat→chat 默认延续。
|
||||
|
||||
需一并评估 `preferDirectChat` 的 sticky 行为(`agent-run-gateway.mjs:1871-1874`):只要 session 仍是 `h5direct_*`,即使 router 判 agent,仍可能优先尝试 direct chat。纠偏链路若不处理这一点会不完整。
|
||||
|
||||
### 11.6 明确后置
|
||||
|
||||
| 项 | 后置理由 |
|
||||
|---|---|
|
||||
| `execute_task` tool calling | 需改 LLM 层 + 同轮状态机 + UI 时序,耦合最重(§11.4.2) |
|
||||
| 收紧 `requiresTaskExecution` 豁免 | 会大幅增加 agent 流量,且可能使 Memory 注入倒退(§12.2) |
|
||||
| P1.2 router 读 `h5direct_` transcript | 与阶段 C 效果重叠,应在 C 之后单独评估 |
|
||||
| P1.3 统一 12/8/16 窗口 | 独立回归面,收益不确定 |
|
||||
| P4 State Card | 阶段 B 已补上下文,边际收益下降;且有 Memory V2 污染风险 |
|
||||
| P6 流式输出 | 体验收益明确但动前后端 Finish 时序,应独立排期 |
|
||||
|
||||
### 11.7 阶段对照与验收
|
||||
|
||||
| 阶段 | 对应 §5 | 风险 | 验收标准 |
|
||||
|---|---|---|---|
|
||||
| A 观测 | P0 | 无 | 拿到 0.72 兜底占比与升级频率基线 |
|
||||
| B 上下文 | P1.1 | 低 | 升级后 Goose 输入含 prior context;memory recall 仍 100% direct;`verify:h5-session-patches` 通过 |
|
||||
| C 语义识别 | P2(修订) | 中,可灰度 | shadow `wouldChangeRoute` 在预期区间;canary 期 agent 触发率增幅低于阈值;`RESOLVED_INJECTED` 比例不下降 |
|
||||
| D 纠偏 | P3(收窄) | 中 | 「不对你去执行」在 `h5direct_` 可进 agent;误触发率低于阈值 |
|
||||
|
||||
回归范围(除 `verify:h5-session-patches` 外):`chat-intent-router.test.mjs`、`direct-chat-service.test.mjs`、`agent-run-gateway.test.mjs`,以及 `scripts/verify-world-cup-flow-e2e.mjs`(该脚本显式断言不应出现 `direct_chat_completed`,对阶段 C 敏感)。
|
||||
|
||||
---
|
||||
|
||||
## 12. Memory V2 兼容性
|
||||
|
||||
### 12.1 现状:三条独立注入路径
|
||||
|
||||
| 路径 | 位置 | 触发条件 | 开关 |
|
||||
|---|---|---|---|
|
||||
| Router | `chat-intent-router.mjs:1537-1548` | 仅记忆召回问题(非召回为 SKIP) | `MEMIND_CHAT_ROUTER_MEMORY_ENABLED`,默认开 |
|
||||
| Direct chat | `direct-chat-service.mjs:302-312` | **每轮**,limit=5 | 不依赖 agent 开关 |
|
||||
| Agent | `agent-run-gateway.mjs:1790-1818` | 注入进 `[Memory Context]` | `MEMORY_AGENT_RESOLVE_ENABLED`,**默认关** |
|
||||
|
||||
关键不对称:**direct chat 每轮必 resolve,agent 默认不注入。**
|
||||
|
||||
### 12.2 各阶段影响
|
||||
|
||||
| 阶段 | 对 Memory V2 的影响 |
|
||||
|---|---|
|
||||
| A 观测 | 无 |
|
||||
| B 上下文 | 低。仅拼接历史文本,不走 resolve/write。风险点是 sessionId 变更导致 episodic 断档 → 由 C6 约束覆盖 |
|
||||
| C 语义识别 | **低于直觉**。router 对非召回问题不调 `memoryV2.resolve`,故不增加负载。真实风险是**流量从 direct(必注入)转向 agent(默认不注入)**,可能造成记忆注入率下降 |
|
||||
| D 纠偏 | 同 C,量级更小 |
|
||||
| 后置项 | State Card 若与 `[Memory Context]` 混写,可能触发候选提取污染(见 [Memory V2 候选与生命周期守卫](../regression-guards/memory-v2-candidate-and-lifecycle.md) 记录的旧记忆自我复制问题) |
|
||||
|
||||
### 12.3 不会被破坏的行为
|
||||
|
||||
候选表与 lifecycle、pgvector、admin 配置、`remember-recent` / `sync` API、fail-open 语义、goosed 零改动承诺——本方案均不触及。
|
||||
|
||||
记忆召回硬规则(`chat-intent-router.mjs:1237`)保证「你记得我上次说的吗」类问题不会被阶段 C 误判进 agent;`isExplicitDirectChatOnlyText` 亦已将 memory recall 纳入排除集(`:337`)。
|
||||
|
||||
### 12.4 强制要求
|
||||
|
||||
1. 阶段 B/C 上线前记录 baseline:`agent_memory_resolved`、`RESOLVED_INJECTED`、`RECALL_HIT` 在两条路径上的分布。
|
||||
2. 阶段 C 推进到 C-2 之前,确认目标 canary 用户的 `MEMORY_AGENT_RESOLVE_ENABLED` 覆盖情况;否则「任务能执行了,但记忆注入变少」。
|
||||
3. 阶段 B 升级路径的 memory resolve 使用旧 sessionId(C6)。
|
||||
4. 任何 State Card 类注入严禁进入 write 链(C1 / C4)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 修订记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|---|---|
|
||||
| 2026-08-28 | 初版:R1–R3 根因核查、原 Task Runtime 方案评估、P0–P6 目标形态 |
|
||||
| 2026-08-28 | 增补 §11 最低影响实施方案:将 P2 的实现方式从 `execute_task` 修订为「让已有 LLM router 在聊天会话可达」,暂缓 tool calling;P1 收窄为仅 P1.1;P3 收窄为仅 `h5direct_` 纠偏 |
|
||||
| 2026-08-28 | 增补 §12 Memory V2 兼容性:三条注入路径现状、各阶段影响、四条强制要求 |
|
||||
@@ -103,6 +103,9 @@ const IMPACT_RULES = Object.freeze([
|
||||
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
|
||||
{ groups: ['MS'], pattern: /mindspace/i },
|
||||
{ groups: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
|
||||
{ groups: ['AGENT', 'CHAT'], pattern: /(?:cursor-openai-bridge|cursor-agent|tool-gateway|help-escalation)/i },
|
||||
{ groups: ['CHAT', 'UI'], pattern: /(?:executor-display-label|tkmind-loading-tips)/i },
|
||||
{ groups: ['MS', 'PAGE'], pattern: /setup-product-order-page/i },
|
||||
{ groups: ['AGENT'], pattern: /(?:agent|capabilit|goosed|worker|aider|mcp|skills-registry)/i },
|
||||
{ groups: ['CHAT'], pattern: /(?:chat|conversation|message|reply|session|sse|routing|intent)/i },
|
||||
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
|
||||
|
||||
@@ -1407,6 +1407,7 @@ attachPortalAgentRuntimeRoutes(api, {
|
||||
getSessionAccess: () => sessionAccess,
|
||||
getMindSpaceAssetAgent: () => mindSpaceAssetAgent,
|
||||
getCodeRunPolicyService: () => agentCodeRunPolicyService,
|
||||
getCursorExecutorPolicyService: () => wechatCursorExecutorPolicyService,
|
||||
getUserToken: userToken,
|
||||
getDeepSearchInternalSecret: () =>
|
||||
DEEP_SEARCH_INTERNAL_SECRET,
|
||||
|
||||
@@ -42,6 +42,7 @@ export function attachPortalAgentRuntimeRoutes(
|
||||
getSessionAccess = () => null,
|
||||
getMindSpaceAssetAgent = () => null,
|
||||
getCodeRunPolicyService = () => null,
|
||||
getCursorExecutorPolicyService = () => null,
|
||||
getUserToken = () => null,
|
||||
getDeepSearchInternalSecret = () => null,
|
||||
bearerToken = () => null,
|
||||
@@ -133,6 +134,7 @@ export function attachPortalAgentRuntimeRoutes(
|
||||
agentRunGateway,
|
||||
mindSpaceAssetAgent: getMindSpaceAssetAgent(),
|
||||
codeRunPolicyService: getCodeRunPolicyService(),
|
||||
cursorExecutorPolicyService: getCursorExecutorPolicyService(),
|
||||
goalRunService: getGoalRunService(),
|
||||
chatIntentRouter: getChatIntentRouter(),
|
||||
templateCatalogService: getTemplateCatalog(),
|
||||
|
||||
@@ -262,6 +262,7 @@ test('proxy-backed routes preserve handler order and fall through after completi
|
||||
getSessionAccess: () => ({ id: 'session-access' }),
|
||||
getMindSpaceAssetAgent: () => ({ id: 'asset-agent' }),
|
||||
getCodeRunPolicyService: () => ({ id: 'code-run-policy' }),
|
||||
getCursorExecutorPolicyService: () => ({ id: 'cursor-policy' }),
|
||||
createPostAgentRunsHandlerFn(dependencies) {
|
||||
calls.push({ kind: 'create-post', dependencies });
|
||||
return (req, _res, next) => {
|
||||
@@ -325,6 +326,7 @@ test('proxy-backed routes preserve handler order and fall through after completi
|
||||
agentRunGateway: { id: 'gateway' },
|
||||
mindSpaceAssetAgent: { id: 'asset-agent' },
|
||||
codeRunPolicyService: { id: 'code-run-policy' },
|
||||
cursorExecutorPolicyService: { id: 'cursor-policy' },
|
||||
goalRunService: null,
|
||||
chatIntentRouter: null,
|
||||
templateCatalogService: null,
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function executeWechatCursorAgentRun({
|
||||
rawToolMode: 'code',
|
||||
taskType: 'wechat_page_generate',
|
||||
env: process.env,
|
||||
channelEligible: true,
|
||||
});
|
||||
userMessage = cursorRuntime.userMessage;
|
||||
|
||||
|
||||
@@ -4,11 +4,18 @@ const POLICY_SOURCE_DEFAULT = 'default';
|
||||
const POLICY_SOURCE_ADMIN_DB = 'admin-db';
|
||||
|
||||
const DEFAULT_INTENT_ALLOWLIST = Object.freeze(['page.generate']);
|
||||
const DEFAULT_CHANNEL_ALLOWLIST = Object.freeze(['h5', 'wechat_mp']);
|
||||
|
||||
export const CURSOR_EXECUTOR_CHANNEL = Object.freeze({
|
||||
H5: 'h5',
|
||||
WECHAT_MP: 'wechat_mp',
|
||||
});
|
||||
|
||||
function defaultConfigShape() {
|
||||
return {
|
||||
enabled: false,
|
||||
userAllowlist: [],
|
||||
channelAllowlist: [...DEFAULT_CHANNEL_ALLOWLIST],
|
||||
intentAllowlist: [...DEFAULT_INTENT_ALLOWLIST],
|
||||
fallbackToDeepseek: true,
|
||||
meta: {
|
||||
@@ -58,6 +65,10 @@ function mergePatch(currentConfig, patch = {}) {
|
||||
const next = cloneConfig(currentConfig);
|
||||
if ('enabled' in patch) next.enabled = normalizeBoolean(patch.enabled, false);
|
||||
if ('userAllowlist' in patch) next.userAllowlist = normalizeStringList(patch.userAllowlist);
|
||||
if ('channelAllowlist' in patch) {
|
||||
const channels = normalizeStringList(patch.channelAllowlist);
|
||||
next.channelAllowlist = channels.length ? channels : [...DEFAULT_CHANNEL_ALLOWLIST];
|
||||
}
|
||||
if ('intentAllowlist' in patch) {
|
||||
const intents = normalizeStringList(patch.intentAllowlist);
|
||||
next.intentAllowlist = intents.length ? intents : [...DEFAULT_INTENT_ALLOWLIST];
|
||||
@@ -73,15 +84,26 @@ function mergePatch(currentConfig, patch = {}) {
|
||||
|
||||
function flattenPolicy(config, source) {
|
||||
const intentAllowlist = normalizeStringList(config.intentAllowlist);
|
||||
const channelAllowlist = normalizeStringList(config.channelAllowlist);
|
||||
return {
|
||||
source,
|
||||
enabled: Boolean(config.enabled),
|
||||
userAllowlist: normalizeStringList(config.userAllowlist),
|
||||
channelAllowlist: channelAllowlist.length ? channelAllowlist : [...DEFAULT_CHANNEL_ALLOWLIST],
|
||||
intentAllowlist: intentAllowlist.length ? intentAllowlist : [...DEFAULT_INTENT_ALLOWLIST],
|
||||
fallbackToDeepseek: config.fallbackToDeepseek !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isChannelAllowedByCursorPolicy(channel, policy) {
|
||||
const allowlist = normalizeStringList(policy?.channelAllowlist ?? DEFAULT_CHANNEL_ALLOWLIST)
|
||||
.map((item) => item.toLowerCase());
|
||||
if (allowlist.length === 0) return false;
|
||||
const normalized = String(channel ?? '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return allowlist.includes(normalized);
|
||||
}
|
||||
|
||||
export function isUserAllowedByWechatCursorPolicy(user, policy) {
|
||||
if (!policy?.enabled) return false;
|
||||
const allowlist = normalizeStringList(policy.userAllowlist).map((item) => item.toLowerCase());
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import {
|
||||
CURSOR_EXECUTOR_CHANNEL,
|
||||
isChannelAllowedByCursorPolicy,
|
||||
isIntentAllowedByWechatCursorPolicy,
|
||||
isUserAllowedByWechatCursorPolicy,
|
||||
} from './wechat-cursor-executor-admin-config.mjs';
|
||||
|
||||
export { CURSOR_EXECUTOR_CHANNEL };
|
||||
|
||||
export function resolveCursorChannelEligible({
|
||||
user = null,
|
||||
userId = null,
|
||||
channel = '',
|
||||
intentKind = '',
|
||||
policy = null,
|
||||
} = {}) {
|
||||
if (!policy?.enabled) return false;
|
||||
const subject = user ?? { userId };
|
||||
if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false;
|
||||
if (!isChannelAllowedByCursorPolicy(channel, policy)) return false;
|
||||
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
|
||||
if (normalizedChannel === CURSOR_EXECUTOR_CHANNEL.WECHAT_MP) {
|
||||
if (!isIntentAllowedByWechatCursorPolicy(intentKind, policy)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveWechatCursorExecutorEligible({
|
||||
user = null,
|
||||
userId = null,
|
||||
intentKind = '',
|
||||
policy = null,
|
||||
} = {}) {
|
||||
if (!policy?.enabled) return false;
|
||||
const subject = user ?? { userId };
|
||||
if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false;
|
||||
if (!isIntentAllowedByWechatCursorPolicy(intentKind, policy)) return false;
|
||||
return true;
|
||||
return resolveCursorChannelEligible({
|
||||
user,
|
||||
userId,
|
||||
channel: CURSOR_EXECUTOR_CHANNEL.WECHAT_MP,
|
||||
intentKind,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWechatCursorExecutorAdminConfigService,
|
||||
isChannelAllowedByCursorPolicy,
|
||||
isUserAllowedByWechatCursorPolicy,
|
||||
isIntentAllowedByWechatCursorPolicy,
|
||||
} from './wechat-cursor-executor-admin-config.mjs';
|
||||
import { resolveWechatCursorExecutorEligible } from './wechat-cursor-executor-policy.mjs';
|
||||
import {
|
||||
CURSOR_EXECUTOR_CHANNEL,
|
||||
resolveCursorChannelEligible,
|
||||
resolveWechatCursorExecutorEligible,
|
||||
} from './wechat-cursor-executor-policy.mjs';
|
||||
|
||||
function createMemoryPool() {
|
||||
const rows = new Map();
|
||||
@@ -89,3 +94,57 @@ test('isIntentAllowedByWechatCursorPolicy respects allowlist', () => {
|
||||
assert.equal(isIntentAllowedByWechatCursorPolicy('page.generate', policy), true);
|
||||
assert.equal(isIntentAllowedByWechatCursorPolicy('chat.general', policy), false);
|
||||
});
|
||||
|
||||
test('H5 channel does not require intent allowlist', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createWechatCursorExecutorAdminConfigService(pool);
|
||||
await service.updateAdminConfig({
|
||||
enabled: true,
|
||||
userAllowlist: ['john-uuid'],
|
||||
channelAllowlist: ['h5', 'wechat_mp'],
|
||||
}, { updatedBy: 'admin-1' });
|
||||
const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' });
|
||||
assert.equal(
|
||||
resolveCursorChannelEligible({
|
||||
user: { userId: 'john-uuid' },
|
||||
channel: CURSOR_EXECUTOR_CHANNEL.H5,
|
||||
policy,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('H5 channel respects channelAllowlist', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createWechatCursorExecutorAdminConfigService(pool);
|
||||
await service.updateAdminConfig({
|
||||
enabled: true,
|
||||
userAllowlist: ['john-uuid'],
|
||||
channelAllowlist: ['wechat_mp'],
|
||||
}, { updatedBy: 'admin-1' });
|
||||
const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' });
|
||||
assert.equal(
|
||||
resolveCursorChannelEligible({
|
||||
user: { userId: 'john-uuid' },
|
||||
channel: CURSOR_EXECUTOR_CHANNEL.H5,
|
||||
policy,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveCursorChannelEligible({
|
||||
user: { userId: 'john-uuid' },
|
||||
channel: CURSOR_EXECUTOR_CHANNEL.WECHAT_MP,
|
||||
intentKind: 'page.generate',
|
||||
policy,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isChannelAllowedByCursorPolicy defaults to both channels', () => {
|
||||
const policy = { channelAllowlist: ['h5', 'wechat_mp'] };
|
||||
assert.equal(isChannelAllowedByCursorPolicy('h5', policy), true);
|
||||
assert.equal(isChannelAllowedByCursorPolicy('wechat_mp', policy), true);
|
||||
assert.equal(isChannelAllowedByCursorPolicy('other', policy), false);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ const DEFAULT_STATUS_TEXT =
|
||||
'我在这边。上一条如果还没完成,我会继续把结果发给你;你也可以直接补一句要求。';
|
||||
const DEFAULT_UNSUPPORTED_TEXT = '当前先支持文字消息,你可以直接发文字给我。';
|
||||
const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agent 对话:';
|
||||
const DEFAULT_SUBSCRIBE_INTRO_URL =
|
||||
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/tkmind-deep-intro.html';
|
||||
const DEFAULT_PROGRESS_DELAY_MS = 8000;
|
||||
const DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1000;
|
||||
const DEFAULT_SESSION_MESSAGE_ROTATE_COUNT = 200;
|
||||
@@ -69,6 +71,8 @@ export function loadWechatMpConfig(env = process.env) {
|
||||
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
|
||||
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
|
||||
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
|
||||
subscribeIntroUrl:
|
||||
env.H5_WECHAT_MP_SUBSCRIBE_INTRO_URL?.trim() || DEFAULT_SUBSCRIBE_INTRO_URL,
|
||||
tokenUrl: env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_WECHAT_TOKEN_URL,
|
||||
customerServiceUrl:
|
||||
env.H5_WECHAT_MP_CUSTOMER_SERVICE_URL?.trim() || DEFAULT_WECHAT_CUSTOMER_SERVICE_URL,
|
||||
|
||||
+5
-1
@@ -42,6 +42,7 @@ import { handleWechatIntentTransaction } from './wechat/handlers/intent-transact
|
||||
import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs';
|
||||
import {
|
||||
buildStatusText,
|
||||
buildSubscribeWelcomeText,
|
||||
resolveSyncReply,
|
||||
shouldHandleSyncReply,
|
||||
} from './wechat/handlers/sync-replies.mjs';
|
||||
@@ -3768,7 +3769,10 @@ export function createWechatMpService({
|
||||
body: buildWechatTextReply({
|
||||
toUserName: inbound.fromUserName,
|
||||
fromUserName: inbound.toUserName,
|
||||
content: `${config.unboundTextPrefix}\n${buildBindUrl()}`,
|
||||
content: buildSubscribeWelcomeText({
|
||||
bindUrl: buildBindUrl(),
|
||||
introUrl: config.subscribeIntroUrl,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import { resolveWechatAddressName } from '../user/display-name.mjs';
|
||||
|
||||
export const DEFAULT_SUBSCRIBE_INTRO_URL =
|
||||
'https://m.tkmind.cn/MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public/tkmind-deep-intro.html';
|
||||
|
||||
/** 103 生产默认绑定入口:{H5_PUBLIC_BASE_URL}/auth/wechat/authorize?intent=login */
|
||||
export const DEFAULT_PRODUCTION_BIND_URL =
|
||||
'https://m.tkmind.cn/auth/wechat/authorize?intent=login';
|
||||
|
||||
export function buildWechatTextLink(href, label) {
|
||||
const normalizedHref = String(href ?? '').trim();
|
||||
const normalizedLabel = String(label ?? '').trim();
|
||||
if (!normalizedHref) return normalizedLabel;
|
||||
if (!normalizedLabel) return normalizedHref;
|
||||
return `<a href="${normalizedHref}">${normalizedLabel}</a>`;
|
||||
}
|
||||
|
||||
export function buildSubscribeWelcomeText({ bindUrl, introUrl = DEFAULT_SUBSCRIBE_INTRO_URL } = {}) {
|
||||
const normalizedBindUrl = String(bindUrl ?? '').trim();
|
||||
const normalizedIntroUrl = String(introUrl ?? '').trim();
|
||||
const lines = [
|
||||
'欢迎关注 TKMind 智趣 👋',
|
||||
'',
|
||||
'在微信里直接发消息即可:',
|
||||
'· 问答、写作、翻译',
|
||||
'· 生成精美网页并给链接',
|
||||
'· 发图解读报告/截图',
|
||||
'· 设置定时提醒与每日推送',
|
||||
];
|
||||
if (normalizedIntroUrl) {
|
||||
lines.push('', '📖 了解更多:', buildWechatTextLink(normalizedIntroUrl, '点击链接'));
|
||||
}
|
||||
if (normalizedBindUrl) {
|
||||
lines.push('', '👉 先完成绑定再开始对话:', buildWechatTextLink(normalizedBindUrl, '点我绑定'));
|
||||
}
|
||||
lines.push('', '绑定后回复「你好」,或试试:', '「帮我做一个读书笔记页面」');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildGreetingText(user) {
|
||||
const name = resolveWechatAddressName(user);
|
||||
return name ? `你好,${name}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildSubscribeWelcomeText,
|
||||
buildWechatTextLink,
|
||||
DEFAULT_PRODUCTION_BIND_URL,
|
||||
DEFAULT_SUBSCRIBE_INTRO_URL,
|
||||
} from './sync-replies.mjs';
|
||||
|
||||
test('buildWechatTextLink renders WeChat anchor without exposing raw url', () => {
|
||||
assert.equal(
|
||||
buildWechatTextLink(DEFAULT_PRODUCTION_BIND_URL, '点我绑定'),
|
||||
`<a href="${DEFAULT_PRODUCTION_BIND_URL}">点我绑定</a>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSubscribeWelcomeText uses clickable anchors instead of raw urls', () => {
|
||||
const text = buildSubscribeWelcomeText({ bindUrl: DEFAULT_PRODUCTION_BIND_URL });
|
||||
|
||||
assert.match(text, /欢迎关注 TKMind 智趣/);
|
||||
assert.match(text, /生成精美网页并给链接/);
|
||||
assert.match(text, /📖 了解更多:/);
|
||||
assert.match(
|
||||
text,
|
||||
/<a href="[^"]*tkmind-deep-intro\.html">点击链接<\/a>/,
|
||||
);
|
||||
assert.match(text, /👉 先完成绑定再开始对话:/);
|
||||
assert.match(
|
||||
text,
|
||||
/<a href="[^"]*auth\/wechat\/authorize\?intent=login">点我绑定<\/a>/,
|
||||
);
|
||||
assert.doesNotMatch(text, /\nhttps:\/\/m\.tkmind\.cn/);
|
||||
assert.match(text, /帮我做一个读书笔记页面/);
|
||||
});
|
||||
|
||||
test('buildSubscribeWelcomeText omits intro section when introUrl is empty', () => {
|
||||
const text = buildSubscribeWelcomeText({
|
||||
bindUrl: 'https://m.tkmind.cn/bind',
|
||||
introUrl: '',
|
||||
});
|
||||
|
||||
assert.doesNotMatch(text, /📖 了解更多:/);
|
||||
assert.doesNotMatch(text, /tkmind-deep-intro\.html/);
|
||||
});
|
||||
Reference in New Issue
Block a user