Compare commits

..

5 Commits

Author SHA1 Message Date
john 3df2c62c3c docs(architecture): add chat-to-task intent layer review and low-impact plan.
Document R1–R3 root causes in the H5 direct/agent routing path, reject the Task Runtime approach, and define a phased rollout with Memory V2 compatibility constraints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:34:59 +08:00
john cebcc85e84 Fix portal agent runtime route test for cursor policy service wiring.
Memind CI / Test, build, and release guards (push) Successful in 5m22s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:33:31 +08:00
john 416bf7a29a Isolate Cursor executor to admin-configured whitelist channel for H5 and WeChat.
Memind CI / Test, build, and release guards (push) Failing after 4m22s
Non-allowlisted users stay on the existing Goose/DeepSeek path; only memindadm whitelist users enter the TKMind Cursor channel on selected intents and channels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:27:10 +08:00
john 02fdcdedc5 fix(release-gate): map cursor/help/tool runtime paths for impact selection
Memind CI / Test, build, and release guards (push) Successful in 3m27s
Unblocks production Gate when promoting commits that touch cursor bridge,
tool gateway, help escalation, loading tips, and setup scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 09:43:17 +08:00
john f91ee97502 feat(wechat): send rich subscribe welcome with clickable intro and bind links
Memind CI / Test, build, and release guards (push) Has been cancelled
New followers receive capability overview plus WeChat anchor links for the
deep intro page and account binding instead of exposing raw URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 09:39:06 +08:00
20 changed files with 999 additions and 43 deletions
+4 -4
View File
@@ -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 executorMindSpace 落盘,推荐 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
+17
View File
@@ -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
View File
@@ -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
+125
View File
@@ -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;
}
});
+2 -1
View File
@@ -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 */
+8 -2
View File
@@ -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),
};
+17 -8
View File
@@ -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 无法解决 R1R3 中的任何一个。** 因此本文提出替代路线:先修复路由层缺陷,让意图识别真正生效;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 chatrouter 做路由判断时看不到它**。这直接解释短消息漂移。
### 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 contextmemory 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 每轮必 resolveagent 默认不注入。**
### 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 使用旧 sessionIdC6)。
4. 任何 State Card 类注入严禁进入 write 链(C1 / C4)。
---
## 13. 修订记录
| 日期 | 变更 |
|---|---|
| 2026-08-28 | 初版:R1R3 根因核查、原 Task Runtime 方案评估、P0P6 目标形态 |
| 2026-08-28 | 增补 §11 最低影响实施方案:将 P2 的实现方式从 `execute_task` 修订为「让已有 LLM router 在聊天会话可达」,暂缓 tool callingP1 收窄为仅 P1.1P3 收窄为仅 `h5direct_` 纠偏 |
| 2026-08-28 | 增补 §12 Memory V2 兼容性:三条注入路径现状、各阶段影响、四条强制要求 |
+3
View File
@@ -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 },
+1
View File
@@ -1407,6 +1407,7 @@ attachPortalAgentRuntimeRoutes(api, {
getSessionAccess: () => sessionAccess,
getMindSpaceAssetAgent: () => mindSpaceAssetAgent,
getCodeRunPolicyService: () => agentCodeRunPolicyService,
getCursorExecutorPolicyService: () => wechatCursorExecutorPolicyService,
getUserToken: userToken,
getDeepSearchInternalSecret: () =>
DEEP_SEARCH_INTERNAL_SECRET,
+2
View File
@@ -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,
+1
View File
@@ -65,6 +65,7 @@ export async function executeWechatCursorAgentRun({
rawToolMode: 'code',
taskType: 'wechat_page_generate',
env: process.env,
channelEligible: true,
});
userMessage = cursorRuntime.userMessage;
+22
View File
@@ -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());
+29 -5
View File
@@ -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,
});
}
+60 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}),
}),
};
}
+37
View File
@@ -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}!我在呢,有什么需要?` : '你好!我在呢,有什么需要?';
+45
View File
@@ -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/);
});