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:
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user