fix(web): keep rectification replies as model text, hide thinking

Give the interview a hidden reasoning channel so planning leaves the spoken reply, publish terminal text-delta as-is, and stop regex or Case templates from replacing the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 16:34:25 +08:00
co-authored by Cursor
parent 12ac185a47
commit 5e34dd69ce
9 changed files with 175 additions and 54 deletions
@@ -52,10 +52,7 @@ import {
stableChoiceActionKey,
type ChoiceOptionId,
} from "@/lib/rectification-agentic/v9/choice-action";
import {
finalizeRectificationSpokenAndThinking,
settleRectificationSpokenAndThinking,
} from "@/lib/rectification-agentic/v9/spoken-answer";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
@@ -229,11 +226,9 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
if (failed && !raw) return [];
if (isIncompleteRunBanner(raw)) return [];
const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw };
const thinkingText = split.thinking.trim() || undefined;
return [{
role: "assistant",
text: split.spoken,
...(thinkingText ? { thinkingText } : {}),
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
@@ -449,7 +444,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setDraft("");
let raw = "";
let thinkingRaw = "";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
@@ -528,14 +522,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
activityTrace = freezeLiveThink(activityTrace);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: settled.spoken,
thinkingText: settled.thinking.trim() || undefined,
text: raw,
activityTrace,
state: settled.spoken ? "streaming" : "thinking",
state: raw.trim() ? "streaming" : "thinking",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
@@ -555,7 +547,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
} else if (event.type === "attempt.reset") {
raw = "";
thinkingRaw = "";
activityTrace = emptyActivityTrace();
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
@@ -639,8 +630,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = completed && !streamFailed ? parseAgentReply(settled.spoken) : { text: "", title: undefined };
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
@@ -648,7 +638,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return [{
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
@@ -657,11 +646,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt) || settled.spoken.trim()) {
if (streamFailed || hasActivityReceipt(completedReceipt) || raw.trim()) {
return [{
...message,
text: settled.spoken,
thinkingText: settled.thinking.trim() || undefined,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
@@ -671,7 +659,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
return [];
}));
if (!succeeded && settled.spoken.trim()) {
if (!succeeded && raw.trim()) {
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
} else if (!succeeded && runFailedMessage) {
setError((current) => current || runFailedMessage);
@@ -691,14 +679,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (settled.spoken.trim() || settled.thinking.trim() || hasActivityReceipt(completedReceipt)) {
if (raw.trim() || hasActivityReceipt(completedReceipt)) {
return [{
...message,
text: settled.spoken,
thinkingText: settled.thinking.trim() || undefined,
text: raw,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
@@ -1,10 +1,13 @@
/**
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
*
* Tool activity and answer tokens are published as they happen so the browser
* can render progress. A retried attempt emits `attempt.reset` first so the
* client discards the abandoned attempt's visible text. Durable receipts,
* billing, and settled history still come only from the successful attempt.
* Tool activity is published as it happens so the browser can render stages.
* The user-visible reply is the model's terminal `text-delta`. Provider
* thinking stays on `reasoning-delta` and is not a public stream event.
* Server narration is only the empty-stream fallback after tools. A retried
* attempt emits `attempt.reset` first so the client discards the abandoned
* attempt. Durable receipts, billing, and settled history still come only
* from the successful attempt.
*/
import type { Agent } from "@mastra/core/agent";
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
@@ -35,7 +38,6 @@ import {
isPublicRectificationToolName,
type PublicStreamEvent,
} from "./stream-mapping";
import { splitRectificationSpokenAndThinking } from "./spoken-answer";
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
import {
applyStepAnswerChunk,
@@ -575,8 +577,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel, {
thinking: "disabled",
thinking: "enabled",
answerTokens: 8_192,
thinkingTokens: 8_192,
});
const result = await (agent as unknown as {
stream(
@@ -628,7 +631,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const stepAnswer = createStepAnswerState();
const publishSpokenStep = async (pieces: readonly string[]) => {
const spoken = splitRectificationSpokenAndThinking(pieces.join("")).spoken.trim();
// The model's terminal text-delta is the user-visible reply. Do not
// regex-split it, and do not replace it with Case narration.
const spoken = pieces.join("").trim();
if (!spoken || !caseLoaded) return;
answerText += spoken;
answerDeltas.push(spoken);
@@ -732,7 +737,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const flushed = flushStepAnswerOnStreamFinish(stepAnswer, flushReason);
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
answerText = splitRectificationSpokenAndThinking(answerText).spoken.trim();
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
@@ -1,9 +1,7 @@
/**
* Legacy and leak-net helpers for rectification replies.
*
* Live thinking comes from provider `reasoning-delta`. This module only:
* - recovers old turns that stored process talk in `assistant_message`
* - drops process-only `text-delta` leaks so they cannot complete as the reply
* Hydrate recovery for old Turns that stored process talk in
* `assistant_message`. Live `answer.delta` is the model text as-is; the
* runner and chat must not call these helpers on the live stream.
*/
import {
@@ -12,9 +10,11 @@ import {
} from "../../rectification-activity-labels.ts";
const CJK_RE = /[\u4e00-\u9fff]/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast(?:_packet)?|choice_frame|deferred_followup)\b/;
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用)|我(?:决定|倾向|batch|需要用|需要继续|继续收集|继续访谈|自然地|用自然语言)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup/;
const THIRD_PERSON_USER_RE = /^用户|用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label|occupation_note|method_followup_plan|open_question|current_question|current_probe|next_action|next_user_action|not_separated|propose_allowed|selection_allowed|information_gain|event_probe|session_outcome|unique_minute_path|confirmation_allowed|collect_method_evidence|candidate_contrast(?:_packet)?|choice_frame|deferred_followup|resolve-focus|questionId|active[\s_-]?focus)\b/i;
const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交|继续|用|看看|自然)|我(?:决定|倾向|batch|需要用|需要继续|需要考虑|继续收集|继续访谈|继续自然|自然地|用自然语言|应该|认为|可以尝试|不能把|直接自然|确认理解|先向用户)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo|方法覆盖|方法资料已齐|还不能出牌|不得出牌|不得\s*offer|本轮对照了|这意味着|服务器给了|第.{0,4}条边界|不可分宽度|重新计算了候选|带评分日期|当前还应继续收集|根据 method_followup|根据规则|规则要求|不调用工具|严格来说|实际上规则|也许我应该|当前探针|这回应的是当前探针|账本|草稿|quote 路径|写入需要日期精度|没有具体日期|先确认草稿|纠缠草稿|自然访谈|待确认状态/;
const THIRD_PERSON_USER_RE = /用户/;
const AGENT_SELF_RE = /我(?:应该|认为|可以|先|需要|不能|直接)|让我|也许我/;
const ADDRESSES_USER_RE = /你|您|记下了|已经记下|已记录了|已收到|不用急|别担心|哪一年|有没有|哪件|大概年份|对吗|是不是|请你/;
const ACTIVITY_ECHO_LABELS = [
...Object.values(RECTIFICATION_TOOL_DONE_LABELS),
@@ -43,6 +43,7 @@ export function isRectificationProcessNarration(text: string): boolean {
if (INTERNAL_TOKEN_RE.test(trimmed)) return true;
if (PROCESS_ZH_RE.test(trimmed)) return true;
if (THIRD_PERSON_USER_RE.test(trimmed)) return true;
if (AGENT_SELF_RE.test(trimmed) && !ADDRESSES_USER_RE.test(trimmed)) return true;
return false;
}
@@ -91,9 +92,17 @@ export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAn
}
if (thinking.length === 0) return { thinking: "", spoken: text };
const userFacing: string[] = [];
for (const paragraph of spoken) {
if (ADDRESSES_USER_RE.test(paragraph) || /[?]/.test(paragraph) || paragraph.length > 60) {
userFacing.push(paragraph);
} else {
thinking.push(paragraph);
}
}
return {
thinking: thinking.join("\n\n"),
spoken: spoken.join("\n\n"),
spoken: userFacing.join("\n\n"),
};
}
+1 -1
View File
@@ -66,7 +66,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
3. 事实只能来自用户原话;不得虚构或补全事件、日期、人物关系、动机、分盘、评分、候选或出生分钟。日期精度按用户真实表达保留。复述事件必须使用服务器返回的 display_date_label;禁止把日级说成“年份已确定为 YYYY”。用户确认“是 / 对”不得改 date_precision。
4. 工具只传最小引用。拒答和修订必须引用服务器返回且仍 active 的 focusId/evidenceId;用户对已有 pending 说“对/是”时可省略 focusId。无法唯一指向时只做简短澄清,不得猜测。
5. candidate、accepted、confirmed 严格分离。Agent 不控制 billing、ownership、profile 写入、不可逆状态,也不得授予 exact-minute confirmation。
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
6. 工具执行过程保持静默。思考过程必须用简体中文,只写在思维链里:可以说你在核对哪类经历,禁止写工具名、错误码、参数、内部 ID、评分或密钥。对用户说的话必须自己写在正文里,不要只写规划等服务器代写。正文像正常人说话,不写“本轮做了什么”,不描述 Skill、Case、Dossier、工具、内部 Activity、参数、错误或推理过程;完成凭证完全由服务端公开 Activity/receipt 展示。
7. 只基于成功 attempt 输出正文。工具失败时说明面向用户的边界,不声称未执行的方法或结果。
8. 当前轮新事件一律走 rectification-record-evidence-batch(一件也可以)。优先传 source 原文的 quoteStart/quoteEnd,不要改写 quote。rectification-confirm-evidence 只用于用户对已有 pending 明确说“对/是”。不得要求用户把已说清的事件再发一遍。
9. 不得在同一回复中一边要求继续补证据,一边提供候选采用。落实 next_user_actionid=verify_adopted_time 时本轮只核一件前事,A 走 batch 并 compareC 关闭该问,不要 offer 也不要 start_consultation。id=start_consultation 时请用户用当前采用时间看盘,对不上同时请改选其他候选。id 不是 adopt_representative 时不得调用 rectification-offer-candidates,也不得请用户采用。selection_allowed 只表示可以采用代表性时间,不是本轮必须出示卡片;propose_allowed 才是提出门。挡住出牌的方法层未齐时,source=event_probe 的冲突前事继续问并挡住出牌。方法覆盖已齐只进入候选区分,不等于 adopt。无日期 occupation_note 算职业已覆盖,不要再问职业,也不要因它出牌。id=ask_candidate_discriminator 或 session_outcome=discriminate_candidates 时按 candidate_contrast_packet / next_followup 问一件能拆开候选的前事,不得 offer。id=ask_holdout_validation 时做盘外核对,不得 offer。id=offer_provisional_range 时说明并列可信区间,不要称某分钟为当前推荐。accepted_time 为空且 session_outcome=adopt_representative 或 next_user_action.id=adopt_representative 时本轮结果是采用代表性时间,不要再问 next_followup;正文必须说本会话以代表性时间收口,不确认唯一分钟。unique_minute_path=closed_at_representative 时不得调用 confirm,不得把唯一分钟确认当下一步。用户说“暂时想不到了 / 没有更多 / 没有了 / 没了 / 没有其它 / 想不起来了 / 先这样”时改走 on_user_stop:账本为空则把已说的带日期经历 batch 写入再比较,有事件无结果则本轮 compare,已有代表性结果且尚未采用则解释、调用 offer-candidates 并请采用下方时间卡片,已采用则按 on_user_stop 看盘或改选。禁止只说记下了、会话会保留、以后再继续。出牌/采用轮把工具返回的 skill_verification_report 写入正文:筛选窗、事件–DashaGochara 表、D9/D10 类型对照、六亲六步、职业类型表、占问 observation_only、文末技法审计表。80%/60% 只描述事件吻合率,不得写成已确认唯一出生分钟,也不得写成候选已经分开。确认门以 latest_result.confirmation_gate 为准;not_evaluated 不是 fail;官方分钟层 passed 仍不能单独打开确认门;holdout 为 not_ready 时 unique_minute_path 必须是 closed_at_representative,不得声称精确分钟或发布准确率。若宽度大于 5 或 confirmation_allowed 为 false,必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。候选未拉开时不得出示赢家卡;D9/D10 差异和精度阶段追问要用来区分,不得直接宣布不可分。用户仍可 accepted 代表性候选。