fix(web): keep thinking off the spoken consult and rectification answer

Enumerate evidence kinds so education cannot be proposed as a kind, and stream Chinese thinking on a separate channel that collapses when the reply arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 21:30:55 +08:00
parent 56577504d8
commit 4e247c112e
27 changed files with 465 additions and 73 deletions
+12 -9
View File
@@ -2,25 +2,28 @@
* Visible-answer generation settings shared by consultation and rectification.
*
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
* `max_tokens` with the spoken answer. Without an explicit visible budget and
* thinking turned off, a finished-looking stream can stop mid-heading with
* `finish_reason=length`. The provider id is repeated under `openai` because
* OpenAI-compatible adapters often look there first.
* `max_tokens` with the spoken answer. Consultation and rectification may
* enable a separate Chinese thinking channel; the spoken answer still uses
* this visible token budget. Default remains disabled for other callers.
*/
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
const thinkingDisabled = { thinking: { type: "disabled" as const } };
type ThinkingMode = "enabled" | "disabled";
export function agentGenerationSettings(model?: unknown) {
export function agentGenerationSettings(
model?: unknown,
options: { thinking?: ThinkingMode } = {},
) {
const thinking = { thinking: { type: (options.thinking ?? "disabled") as ThinkingMode } };
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
? model.providerId
: undefined;
const providerOptions: Record<string, typeof thinkingDisabled> = {
openai: thinkingDisabled,
const providerOptions: Record<string, typeof thinking> = {
openai: thinking,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
if (providerId) providerOptions[providerId] = thinking;
return {
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
providerOptions,
+3
View File
@@ -43,6 +43,7 @@ export function activityElapsedLabel(startedAt: number, now: number): string | n
export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
readonly thinkingText?: string;
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: WorkflowReceipt;
@@ -59,6 +60,7 @@ export function chatMessageViews(
loading: boolean,
streamingText: string,
activity?: AgentActivityView,
thinkingText?: string,
): readonly ChatMessageView[] {
const settled = messages.map((message, index) => ({
...message,
@@ -72,6 +74,7 @@ export function chatMessageViews(
{
role: "assistant",
text: streamingText,
thinkingText,
renderKey: `message-${messages.length}`,
state: streamingText ? "streaming" : "thinking",
activity,
@@ -93,6 +93,7 @@ const toolFailedSchema = z.object({
code: z.enum(["calculation_failed", "timeout", "cancelled"]),
}).strict();
const answerDeltaSchema = z.object({ type: z.literal("answer.delta"), text: z.string() }).strict();
const thinkingDeltaSchema = z.object({ type: z.literal("thinking.delta"), text: z.string() }).strict();
const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
@@ -107,7 +108,7 @@ const runFailedSchema = z.object({
export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
runStartedSchema, skillStartedSchema, skillCompletedSchema, toolStartedSchema, activitySchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, runCompletedSchema, runFailedSchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, thinkingDeltaSchema, runCompletedSchema, runFailedSchema,
]);
export type ConsultationAgentPublicEvent = z.infer<typeof consultationAgentPublicEventSchema>;
+19
View File
@@ -0,0 +1,19 @@
/**
* Sanitize text that may appear on the public thinking channel.
*
* English-only process narration, tool ids and UUIDs stay off the client.
* Chinese thinking fragments are allowed through a dedicated event type,
* never through the spoken answer.
*/
const CJK_RE = /[\u4e00-\u9fff]/;
const PUBLIC_THINKING_UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
const PUBLIC_THINKING_TOOL_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+/gi;
export function sanitizePublicThinkingText(text: string): string | null {
const cleaned = text
.replace(PUBLIC_THINKING_UUID_RE, "")
.replace(PUBLIC_THINKING_TOOL_RE, "");
if (!cleaned) return null;
if (/[A-Za-z]{4,}/.test(cleaned) && !CJK_RE.test(cleaned)) return null;
return cleaned.slice(0, 4_000);
}
@@ -29,6 +29,8 @@ import {
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
streamToolNames,
isPublicRectificationToolName,
type PublicStreamEvent,
@@ -460,6 +462,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let skillBound = true;
let caseLoaded = false;
let toolFailedPending = false;
let intentClassified = false;
let streamFailed = false;
let finished = false;
@@ -474,7 +477,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let phaseSequence = 0;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
if (
phase === "answer.delta"
|| phase === "thinking.delta"
|| phase === "attempt.reset"
|| emittedKeys.has(`${phase}:${tool ?? ""}`)
) return;
emittedKeys.add(`${phase}:${tool ?? ""}`);
phases.push(phase);
phaseSequence += 1;
@@ -501,7 +509,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel);
const generation = agentGenerationSettings(options.generationModel, { thinking: "enabled" });
const result = await (agent as unknown as {
stream(
messages: unknown[],
@@ -509,7 +517,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
maxSteps: number;
abortSignal: AbortSignal;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking: { type: "disabled" } }>;
providerOptions?: Record<string, { thinking: { type: "disabled" | "enabled" } }>;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
@@ -567,10 +575,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
await publish(activityEvent);
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
if (activityEvent.status === "failed") {
toolFailedPending = true;
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
} else if (activityEvent.status === "completed") {
toolFailedPending = false;
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const thinkingEvent = mapStreamChunkToThinking(chunk as never);
if (thinkingEvent) await publish(thinkingEvent);
const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
if (phaseEvent.type === "skill.bound" && !skillBound) {
@@ -591,11 +605,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (phaseEvent.type === "answer.delta") {
const text = phaseEvent.text ?? "";
if (text) {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
if (!caseLoaded || toolFailedPending) {
const thinking = toPublicThinkingDelta(text);
if (thinking) await publish(thinking);
} else {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
}
}
}
} else {
@@ -627,6 +646,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
status: "retryable",
errorCode: "focus_persistence_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
if (finishReason === "length") {
return {
@@ -644,22 +679,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
attemptId,
};
}
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
status: "retryable",
errorCode: "focus_persistence_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
await recordPhase("answer.composed");
@@ -2,6 +2,7 @@
* V9 evidence model contracts: event kinds, domains, date precision and the
* append-only revision/status machine. IDs are always generated by the server.
*/
import { z } from "zod";
export const EVIDENCE_KINDS = [
"education_start",
@@ -59,6 +60,13 @@ export const EVIDENCE_DOMAINS = [
export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number];
export const evidenceKindSchema = z.enum(
EVIDENCE_KINDS as unknown as [EvidenceKind, ...EvidenceKind[]],
);
export const evidenceDomainSchema = z.enum(
EVIDENCE_DOMAINS as unknown as [EvidenceDomain, ...EvidenceDomain[]],
);
export const DATE_PRECISIONS = [
"year",
"month",
@@ -2,8 +2,10 @@
* V9 public execution receipts and activity event allowlists.
*
* The web client may only ever see allowlisted phases, tool names and
* activity events. Reasoning, raw tool payloads, internal scores, birth data
* and provider metadata must never reach the client.
* activity events. Raw provider reasoning payloads, tool payloads, internal
* scores, birth data and provider metadata must never reach the client.
* Sanitized Chinese `thinking.delta` is a separate public channel from the
* spoken answer.
*/
export const PUBLIC_RECTIFICATION_PHASES = [
@@ -23,6 +25,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"answer.composed",
"billing.settled",
"answer.delta",
"thinking.delta",
"attempt.reset",
"run.completed",
"run.failed",
@@ -161,8 +164,8 @@ export function safeActivityEvent(
* birth snapshot, raw scores and internal identifiers are not.
*/
export const DENIED_PUBLIC_CONTENT = [
"chain-of-thought",
"reasoning",
"raw provider chain-of-thought payload",
"unsanitized reasoning",
"tool payload",
"baseline birth snapshot",
"user_id",
@@ -2,9 +2,10 @@
* V9 fullStream → public NDJSON mapping.
*
* Consumes the Mastra agent's fullStream (AgentChunkType) and emits only the
* allowlisted public phases from public-receipt.ts. Reasoning, raw payloads,
* provider metadata, tool args/results, system/skill source text, birth data
* and internal errors are dropped at this boundary.
* allowlisted public phases from public-receipt.ts. Raw provider reasoning
* payloads, tool args/results, system/skill source text, birth data and
* internal errors are dropped at this boundary. Chinese thinking is mapped
* separately onto `thinking.delta`, never onto the spoken answer.
*/
import type { AgentChunkType } from "@mastra/core/stream";
import {
@@ -17,6 +18,7 @@ import {
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
import { sanitizePublicThinkingText } from "../../public-thinking";
export type PublicPhaseStreamEvent = Readonly<{
type: PublicRectificationPhase;
@@ -134,12 +136,24 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE
// Failure is decided by the runner so receipts stay accurate.
return null;
default:
// reasoning-*, raw, step-*, source, file, response-metadata and any
// future chunk type are never forwarded.
// Raw reasoning, payloads, step internals and provider metadata stay off
// the answer channel. Chinese thinking is mapped separately.
return null;
}
}
export function toPublicThinkingDelta(text: string): PublicPhaseStreamEvent | null {
const cleaned = sanitizePublicThinkingText(text);
if (!cleaned) return null;
return { type: "thinking.delta", text: cleaned };
}
export function mapStreamChunkToThinking(chunk: AgentChunkType): PublicPhaseStreamEvent | null {
if (chunk.type !== "reasoning-delta") return null;
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
return toPublicThinkingDelta(text);
}
/**
* Project real public tool lifecycle events for the live UI. This stream is
* deliberately separate from the durable phase receipt: it never exposes
@@ -214,10 +228,10 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
}
const type = safeActivityEvent(event.type);
if (!type) return null;
const text = type === "answer.delta" && typeof event.text === "string"
const text = (type === "answer.delta" || type === "thinking.delta") && typeof event.text === "string"
? event.text.slice(0, 4_000)
: undefined;
const tool = type !== "answer.delta" && isPublicRectificationTool(event.tool)
const tool = type !== "answer.delta" && type !== "thinking.delta" && isPublicRectificationTool(event.tool)
? event.tool
: undefined;
const methods = tool && METHOD_TOOLS.has(tool) && Array.isArray(event.methods)
@@ -11,6 +11,7 @@ import {
} from "./consultation-agent-events.ts";
import { toAgentModelFinishReason } from "./agent-observability.ts";
import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { sanitizePublicThinkingText } from "./public-thinking.ts";
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown };
type ChunkStream = AsyncIterable<unknown> | ReadableStream<unknown>;
@@ -229,6 +230,10 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
events.push({ type: "answer.delta", text: chunk.payload.text });
}
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
const thinking = sanitizePublicThinkingText(chunk.payload.text);
if (thinking) events.push({ type: "thinking.delta", text: thinking });
}
}
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
@@ -320,6 +325,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
await outputText(visible.push(chunk.payload.text));
}
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
const thinking = sanitizePublicThinkingText(chunk.payload.text);
if (thinking) send(controller, { type: "thinking.delta", text: thinking });
}
}
await outputText(visible.finish(""));
} catch (error) {