fix(web): isolate rectification answers from tool-step planning text

Mastra intermediate text-delta was published as answer.delta, then set-focus domain errors reset the attempt and replayed evidence. Publish only the terminal no-tool step, persist the next probe on the server, and ground batch quotes in the source turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-24 21:26:57 +08:00
parent 167bdad20d
commit fe87a9ecdb
30 changed files with 1373 additions and 237 deletions
@@ -20,6 +20,7 @@ import {
type V9CaseDossier,
} from "./tool-service";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status";
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import {
@@ -36,6 +37,18 @@ import {
} from "./stream-mapping";
import { splitRectificationSpokenAndThinking } from "./spoken-answer";
import { mapModelFinishToErrorCode, userFacingRunFailure } from "./run-diagnostic";
import {
applyStepAnswerChunk,
createStepAnswerState,
flushStepAnswerOnStreamFinish,
} from "./step-answer";
import { composeRectificationTurnNarration, publicNarrationDtoFromDossier } from "./turn-narration";
import {
defaultMessageOrigin,
isRectificationMessageOrigin,
messageContentHash,
type RectificationMessageOrigin,
} from "./message-origin";
export type V9RunBilling = Readonly<{
reserve(): Promise<{ success: boolean; reason?: string; status: number }>;
@@ -50,6 +63,8 @@ export type V9AgentRunOptions = Readonly<{
requestId: string;
action: RectificationAgentAction;
message: string | null;
messageOrigin?: RectificationMessageOrigin;
clientActionId?: string | null;
modelName: string;
skillName?: string;
skillVersion?: string;
@@ -94,16 +109,14 @@ type AttemptOutcome = Readonly<{
attemptId: string;
}>;
const REPEATED_TOOL_CALL_LIMIT = 3;
const REPEATED_TOOL_CALL_LIMIT = 1;
const MAX_ATTEMPTS = 2;
const RETRYABLE_ERROR_CODES = new Set([
"empty_stream",
"stream_aborted",
"stream_unfinished",
"skill_not_loaded",
"skill_not_bound",
"case_not_loaded",
"focus_persistence_failed",
]);
function streamFinishReason(chunk: {
@@ -111,7 +124,9 @@ function streamFinishReason(chunk: {
payload?: { stepResult?: { reason?: unknown }; reason?: unknown };
}): ReturnType<typeof toAgentModelFinishReason> | null {
if (chunk.type !== "finish") return null;
return toAgentModelFinishReason(chunk.payload?.stepResult?.reason ?? chunk.payload?.reason);
const raw = chunk.payload?.stepResult?.reason ?? chunk.payload?.reason;
if (typeof raw !== "string" || !raw.trim()) return null;
return toAgentModelFinishReason(raw);
}
function first(value: unknown): unknown {
@@ -241,6 +256,21 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
throw new RectificationToolServiceError("agentic_rectification_turn_incomplete");
}
try {
await rpcOf(accounting, "record_agentic_rectification_turn_origin", {
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_origin: isRectificationMessageOrigin(options.messageOrigin)
? options.messageOrigin
: defaultMessageOrigin(options.action),
p_client_action_id: options.clientActionId ?? options.requestId,
p_content_hash: messageContentHash(message),
});
} catch {
// Origin is audit metadata; a missing RPC must not fail the turn.
}
const shouldExecute = turnRecord?.should_execute === undefined
? true
: turnRecord.should_execute === true;
@@ -480,7 +510,6 @@ 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;
@@ -545,7 +574,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
} | undefined;
};
},
): Promise<{
fullStream: AsyncIterable<{
@@ -574,10 +603,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
activeTools: ["rectification-read-case"],
toolChoice: "auto",
}
: undefined,
: {
activeTools: [...RECTIFICATION_AGENT_TOOLS],
toolChoice: "auto",
},
});
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
const stepAnswer = createStepAnswerState();
const publishSpokenStep = async (pieces: readonly string[]) => {
const spoken = splitRectificationSpokenAndThinking(pieces.join("")).spoken.trim();
if (!spoken || !caseLoaded) return;
answerText += spoken;
answerDeltas.push(spoken);
await emit({ type: "answer.delta", text: spoken });
};
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
@@ -596,6 +637,13 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const stepEffect = applyStepAnswerChunk(
stepAnswer,
chunk,
isPublicRectificationToolName,
);
if (stepEffect.kind === "publish") await publishSpokenStep(stepEffect.pieces);
const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
await publish(activityEvent);
@@ -606,11 +654,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish(changed);
}
}
if (activityEvent.status === "failed") {
toolFailedPending = true;
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
} else if (activityEvent.status === "completed") {
toolFailedPending = false;
if (activityEvent.status === "failed" || activityEvent.status === "completed") {
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
@@ -631,33 +675,18 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (phaseEvent.type === "case.loaded" && !skillBound) {
throw new Error("skill_not_bound");
}
if (phaseEvent.type === "answer.delta") {
const text = phaseEvent.text ?? "";
if (text) {
if (!caseLoaded || toolFailedPending) {
// Process talk before Case load stays off the public stream.
} else if (/[A-Za-z]{4,}/.test(text) && !/[\u4e00-\u9fff]/.test(text)) {
// English-only process talk stays off the spoken channel.
} else {
answerText += text;
answerDeltas.push(text);
await emit({ type: "answer.delta", text });
}
}
} else {
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedKeys.has(`event:${key}`)) {
emittedKeys.add(`event:${key}`);
await publish(phaseEvent);
}
if (phaseEvent.type === "case.loaded") {
caseLoaded = true;
if (!intentClassified) {
intentClassified = true;
await recordPhase("intent.classified");
await publish({ type: "intent.classified" });
}
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedKeys.has(`event:${key}`)) {
emittedKeys.add(`event:${key}`);
await publish(phaseEvent);
}
if (phaseEvent.type === "case.loaded") {
caseLoaded = true;
if (!intentClassified) {
intentClassified = true;
await recordPhase("intent.classified");
await publish({ type: "intent.classified" });
}
}
}
@@ -669,6 +698,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
if (finished && !streamFailed) {
const flushReason = finishReason === "length"
? "length"
: finishReason === "stop" || finishReason === "unknown" || finishReason === null
? "stop"
: finishReason;
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");
@@ -684,22 +722,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (mapped === "run_timeout") return failedAttempt(attemptId, "run_timeout");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
if (!finished) return failedAttempt(attemptId, mapped ?? "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 (mapped === "answer_truncated") {
return {
ok: false,
@@ -719,6 +741,19 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
if (narration.trim()) {
answerText = narration;
answerDeltas.push(narration);
await emit({ type: "answer.delta", text: narration });
}
} catch {
// Fall through to empty_stream.
}
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
@@ -1,9 +1,9 @@
/**
* Choice-card contract for birth-time rectification.
*
* The server owns the discriminator frame and the tap chrome (A/B/C/D keys,
* C = neither / D = unsure roles, scoring vs holdout, 先这样). The Agent
* writes the question and all four option labels. The browser never invents
* The server owns the discriminator frame, the question copy, and the tap
* chrome (A/B/C/D keys, C = neither / D = unsure roles, scoring vs holdout,
* 先这样). The Agent does not write choice labels. The browser never invents
* option copy, and never parses A/B/C/D out of assistant prose.
*/
@@ -317,6 +317,25 @@ function clippedCopy(value: unknown, min: number, max: number): string | null {
return text;
}
export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentChoiceCopy | null {
const prompt = clippedCopy(`${frame.period},有没有这件事?`, 4, 80)
?? clippedCopy(frame.period, 4, 80);
const optionA = clippedCopy(frame.option_a_hint, 4, 80);
const optionB = clippedCopy(frame.option_b_hint, 4, 80);
const optionC = clippedCopy(frame.neither_label, 4, 80);
const optionD = clippedCopy(frame.unsure_label, 4, 80);
if (!prompt || !optionA || !optionB || !optionC || !optionD) return null;
const labels = [optionA, optionB, optionC, optionD];
if (new Set(labels).size !== labels.length) return null;
return {
prompt,
option_a: optionA,
option_b: optionB,
option_c: optionC,
option_d: optionD,
};
}
export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
@@ -0,0 +1,78 @@
/**
* Ground evidence quotes in the source user turn. The model may propose
* offsets or a quote string; the server always slices the original message.
*/
const QUOTE_PUNCT = /[\s\u3000,。!?、;:“”‘’()《》·—…,!.;:?]/g;
export function normalizeRectificationQuote(value: string): string {
return value.toLowerCase().replace(QUOTE_PUNCT, "");
}
export type EvidenceQuoteInput = Readonly<{
quote?: string | null;
quoteStart?: number | null;
quoteEnd?: number | null;
}>;
export type ResolvedEvidenceQuote =
| { ok: true; quote: string; quoteStart: number; quoteEnd: number }
| { ok: false; errorCode: "quote_mismatch" };
function sliceByOffsets(source: string, start: number, end: number): ResolvedEvidenceQuote {
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > source.length || start >= end) {
return { ok: false, errorCode: "quote_mismatch" };
}
const quote = source.slice(start, end);
if (quote.trim().length < 2) return { ok: false, errorCode: "quote_mismatch" };
return { ok: true, quote, quoteStart: start, quoteEnd: end };
}
function sliceByNormalizedQuote(source: string, quote: string): ResolvedEvidenceQuote {
const needle = normalizeRectificationQuote(quote);
if (needle.length < 2) return { ok: false, errorCode: "quote_mismatch" };
const map: number[] = [];
let normalized = "";
for (let index = 0; index < source.length; index += 1) {
const char = source[index] ?? "";
if (QUOTE_PUNCT.test(char)) continue;
normalized += char.toLowerCase();
map.push(index);
}
const at = normalized.indexOf(needle);
if (at < 0) return { ok: false, errorCode: "quote_mismatch" };
const start = map[at];
const last = map[at + needle.length - 1];
if (start === undefined || last === undefined) return { ok: false, errorCode: "quote_mismatch" };
return sliceByOffsets(source, start, last + 1);
}
export function resolveEvidenceQuote(
sourceTurnText: string | null | undefined,
input: EvidenceQuoteInput,
): ResolvedEvidenceQuote {
const source = sourceTurnText ?? "";
if (!source.trim()) return { ok: false, errorCode: "quote_mismatch" };
if (input.quoteStart != null && input.quoteEnd != null) {
return sliceByOffsets(source, input.quoteStart, input.quoteEnd);
}
const quote = typeof input.quote === "string" ? input.quote.trim() : "";
if (!quote) return { ok: false, errorCode: "quote_mismatch" };
return sliceByNormalizedQuote(source, quote);
}
export function publicEvidenceItemStatus(input: {
outcome: string;
idempotent: boolean;
errorCode: string | null;
}): "created" | "already_exists" | "needs_clarification" | "quote_mismatch" | "rejected" {
if (input.errorCode === "quote_not_grounded" || input.errorCode === "quote_mismatch") {
return "quote_mismatch";
}
if (input.idempotent && (input.outcome === "accepted" || input.outcome === "already_exists")) {
return "already_exists";
}
if (input.outcome === "accepted") return "created";
if (input.outcome === "needs_clarification") return "needs_clarification";
return "rejected";
}
@@ -40,6 +40,7 @@ export function askedProbeKeysFromReceipt(
for (const item of answers) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (typeof row.probe_id === "string") keys.push(row.probe_id);
if (typeof row.semantic_key === "string") keys.push(row.semantic_key);
if (typeof row.candidate_split_hash === "string") keys.push(row.candidate_split_hash);
}
@@ -0,0 +1,27 @@
import { createHash } from "node:crypto";
export const RECTIFICATION_MESSAGE_ORIGINS = [
"typed",
"suggestion_click",
"choice_click",
"voice_input",
"retry_replay",
"system_recovery",
] as const;
export type RectificationMessageOrigin = (typeof RECTIFICATION_MESSAGE_ORIGINS)[number];
export function isRectificationMessageOrigin(value: unknown): value is RectificationMessageOrigin {
return typeof value === "string"
&& (RECTIFICATION_MESSAGE_ORIGINS as readonly string[]).includes(value);
}
export function defaultMessageOrigin(action: string): RectificationMessageOrigin {
if (action === "answer_choice" || action === "stop_and_review") return "choice_click";
if (action === "opening") return "system_recovery";
return "typed";
}
export function messageContentHash(text: string | null | undefined): string {
return createHash("sha256").update(text ?? "").digest("hex");
}
@@ -293,11 +293,11 @@ function coverage(
}
function collectHint(why: string, varga: string, extra = ""): string {
return `${why}本题绑定 ${varga}${extra}用自然语言问一件带大概年份的经历。set-focus 不要写 expectedAnswerSchema.choice,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}用自然语言问一件带大概年份的经历。不要调用 set-focus,界面不出点选卡。允许模糊年份。`.replace(/\s+/g, " ").trim();
}
function agentHint(why: string, varga: string, extra = ""): string {
return `${why}本题绑定 ${varga}${extra}根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡。题干由你写成自然语言;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份不要照抄 hint。A 是这件事大概就在那段时间,B 是有类似但年份不对或不够重大,C 是没有明显发生,D 是不记得。正文不要复述选项。`.replace(/\s+/g, " ").trim();
return `${why}本题绑定 ${varga}${extra}点选卡已由服务器按 choice_frame 持久化。用简体中文只问这一句已持久化的题干;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim();
}
export function shouldAttachChoiceFrame(
@@ -634,7 +634,7 @@ export function buildMethodFollowupPlan(input: {
domain: focus.targetDomain,
kind_hint: focus.targetKind,
user_prompt_hint: keepChoice
? "先承接当前服务器焦点。根据 choice_frame 自己写题干和 A/B/C/D,经 set-focus.expectedAnswerSchema.choice 交给点选卡;年份不得发明不要照抄 hint。正文不要复述选项。"
? "先承接当前服务器已持久化的焦点和点选卡。用简体中文只问这一句;年份不得发明不要调用 set-focus。正文不要复述选项。"
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则继续用自然语言问一件带大概年份的事。不要写 expectedAnswerSchema.choice。",
source: "active_focus",
}, true, keepChoice),
@@ -54,6 +54,10 @@ export const PUBLIC_RECTIFICATION_TOOLS = [
export type PublicRectificationTool =
(typeof PUBLIC_RECTIFICATION_TOOLS)[number];
export const RECTIFICATION_AGENT_TOOLS = PUBLIC_RECTIFICATION_TOOLS.filter(
(tool) => tool !== "rectification-set-focus",
);
export const PUBLIC_RECTIFICATION_METHODS = [
"d1-rashi",
"d2-hora",
@@ -115,6 +119,7 @@ export type RectificationActivityEvent = Readonly<{
tool: PublicRectificationTool;
status: RectificationActivityStatus;
methods?: readonly PublicRectificationMethod[];
code?: string;
}>;
export const RECEIPT_STATUSES = [
@@ -0,0 +1,157 @@
import {
serverOwnedChoiceCopy,
type RectificationChoiceFrame,
} from "./choice-card";
import {
askedProbeKeysFromReceipt,
stampChoiceSchemaWithProbe,
previousInferenceFromReceipt,
} from "./inference-adapter";
import type { MethodFollowup } from "./method-followup";
import {
setV10ConversationFocus,
RectificationToolServiceError,
safeToolErrorCode,
type AccountingClient,
type ConversationFocus,
} from "./tool-service";
export type PersistServerFocusStatus =
| "created"
| "already_open"
| "duplicate_focus"
| "probe_already_answered"
| "zero_information_gain"
| "skipped";
export type PersistServerFocusResult = Readonly<{
status: PersistServerFocusStatus;
focus: ConversationFocus | null;
questionId: string | null;
prompt: string | null;
}>;
export function stableFollowupQuestionId(followup: MethodFollowup): string {
if (followup.semantic_key) return `probe:${followup.semantic_key}`.slice(0, 160);
if (followup.probe_year && followup.domain) {
return `${followup.method_id}:${followup.domain}:${followup.probe_year}`.slice(0, 160);
}
return (followup.choice_frame?.question_id ?? `${followup.method_id}:${followup.ask_theme}`).slice(0, 160);
}
function schemaProbeId(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
const probeId = schema?.probe_id;
return typeof probeId === "string" && probeId.trim() ? probeId : null;
}
export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): PersistServerFocusStatus | null {
if (followup.source === "event_probe" && (followup.information_gain ?? 0) <= 0) {
return "zero_information_gain";
}
return null;
}
function expectedAnswerSchemaFor(
frame: RectificationChoiceFrame,
questionId: string,
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined,
): Record<string, unknown> | null {
const copy = serverOwnedChoiceCopy(frame);
if (!copy) return null;
const schema: Record<string, unknown> = {
choice: {
prompt: copy.prompt,
option_a: copy.option_a,
option_b: copy.option_b,
option_c: copy.option_c,
option_d: copy.option_d,
},
};
return stampChoiceSchemaWithProbe(
schema,
previousInferenceFromReceipt(decisionReceipt ?? null),
questionId,
);
}
export async function persistServerOwnedFocus(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
activeFocus: ConversationFocus | null;
decisionReceipt: Readonly<Record<string, unknown>> | null | undefined;
followup: MethodFollowup | null;
}): Promise<PersistServerFocusResult> {
const followup = input.followup;
const frame = followup?.choice_frame ?? null;
if (!followup || !frame) {
return { status: "skipped", focus: input.activeFocus, questionId: null, prompt: null };
}
const skip = shouldSkipDiscriminatorFollowup(followup);
if (skip) {
return { status: skip, focus: input.activeFocus, questionId: null, prompt: null };
}
const questionId = stableFollowupQuestionId(followup);
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt);
if (!schema?.choice) {
return { status: "skipped", focus: input.activeFocus, questionId, prompt: null };
}
const copy = serverOwnedChoiceCopy(frame);
const prompt = copy?.prompt ?? null;
const answeredKeys = new Set(askedProbeKeysFromReceipt(input.decisionReceipt));
if (followup.semantic_key && answeredKeys.has(followup.semantic_key)) {
return {
status: "probe_already_answered",
focus: input.activeFocus,
questionId,
prompt: null,
};
}
const active = input.activeFocus;
if (
active
&& (
active.questionId === questionId
|| (schemaProbeId(schema) && schemaProbeId(active.expectedAnswerSchema) === schemaProbeId(schema))
)
) {
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
}
if (followup.source === "event_probe" && !schemaProbeId(schema)) {
return {
status: "probe_already_answered",
focus: input.activeFocus,
questionId,
prompt: null,
};
}
try {
const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, {
questionId,
intent: followup.intent,
targetEvidenceId: null,
targetDomain: followup.domain,
targetKind: null,
expectedAnswerSchema: schema,
});
return {
status: result.idempotent ? "already_open" : "created",
focus: result.focus,
questionId: result.focus.questionId,
prompt,
};
} catch (error) {
const code = error instanceof RectificationToolServiceError
? error.code
: safeToolErrorCode(error);
if (code === "focus_idempotency_conflict" || code.includes("focus_idempotency_conflict")) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
throw error;
}
}
@@ -0,0 +1,123 @@
/**
* Isolate the terminal no-tool step from Mastra multi-step agent runs.
*
* `text-delta` is not a user answer. A tool-using step may emit planning
* prose before the call; that text must be discarded as a whole. Only a
* step that never called a public tool and finished with `stop` may become
* `answer.delta`.
*/
export type StepAnswerChunk = Readonly<{
type: string;
payload?: {
text?: unknown;
toolName?: unknown;
stepResult?: { reason?: unknown };
reason?: unknown;
};
}>;
export type StepAnswerState = {
text: string;
pieces: string[];
calledTool: boolean;
};
export type StepAnswerEffect =
| { kind: "none" }
| { kind: "discard" }
| { kind: "publish"; pieces: readonly string[] };
function reset(state: StepAnswerState): void {
state.text = "";
state.pieces = [];
state.calledTool = false;
}
export function createStepAnswerState(): StepAnswerState {
return { text: "", pieces: [], calledTool: false };
}
export function stepFinishReason(chunk: StepAnswerChunk): string | null {
if (chunk.type !== "step-finish" && chunk.type !== "finish") return null;
const raw = chunk.payload?.stepResult?.reason ?? chunk.payload?.reason;
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
}
export function shouldPublishStepText(
state: Pick<StepAnswerState, "calledTool" | "text">,
reason: string | null,
): boolean {
if (state.calledTool) return false;
if (!state.text.trim()) return false;
return reason === "stop" || reason === "length" || reason === null;
}
function isPublicToolCall(
chunk: StepAnswerChunk,
isPublicTool: (name: string) => boolean,
): boolean {
const name = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
return Boolean(name) && name !== "skill" && isPublicTool(name);
}
/**
* Advance the per-step buffer. Publishing happens only on `step-finish`
* (or a later `flushStepAnswerOnStreamFinish` when Mastra omitted it).
* Tool-result ends the current step so later text is a new step.
*/
export function applyStepAnswerChunk(
state: StepAnswerState,
chunk: StepAnswerChunk,
isPublicTool: (name: string) => boolean,
): StepAnswerEffect {
switch (chunk.type) {
case "reasoning-start":
case "reasoning-delta":
case "reasoning-end":
return { kind: "none" };
case "step-start":
reset(state);
return { kind: "none" };
case "text-delta": {
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
if (text) {
state.text += text;
state.pieces.push(text);
}
return { kind: "none" };
}
case "tool-call":
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
return { kind: "none" };
case "tool-result":
case "tool-error": {
if (isPublicToolCall(chunk, isPublicTool)) state.calledTool = true;
const discarded = state.calledTool || state.text.length > 0;
reset(state);
return discarded ? { kind: "discard" } : { kind: "none" };
}
case "step-finish": {
const reason = stepFinishReason(chunk);
const publish = shouldPublishStepText(state, reason);
const pieces = publish ? [...state.pieces] : [];
reset(state);
return publish ? { kind: "publish", pieces } : { kind: "discard" };
}
default:
return { kind: "none" };
}
}
export function flushStepAnswerOnStreamFinish(
state: StepAnswerState,
finishReason: string | null,
): StepAnswerEffect {
if (!shouldPublishStepText(state, finishReason === "stop" || finishReason === null ? "stop" : finishReason)) {
reset(state);
return { kind: "none" };
}
const pieces = [...state.pieces];
reset(state);
return { kind: "publish", pieces };
}
@@ -144,11 +144,8 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE
const methods = METHOD_TOOLS.has(toolName) ? resultMethods(chunk) : [];
return phase ? { type: phase, tool: toolName, ...(methods.length > 0 ? { methods } : {}) } : null;
}
case "text-delta": {
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
if (!text) return null;
return { type: "answer.delta", text };
}
case "text-delta":
return null;
case "finish":
// Completion is decided by the runner after the skill/first-turn gates;
// a finish chunk alone never proves a settled answer.
@@ -186,6 +183,34 @@ export function mapStreamChunkToThinking(chunk: AgentChunkType): InternalThinkin
* deliberately separate from the durable phase receipt: it never exposes
* args, results, provider errors, scores, birth data or permission flags.
*/
const SAFE_TOOL_ACTIVITY_CODES = new Set([
"duplicate_focus",
"probe_already_answered",
"stale_revision",
"quote_mismatch",
"quote_not_grounded",
"zero_information_gain",
"invalid_tool_input",
"invalid_choice_copy",
"already_exists",
"focus_idempotency_conflict",
"invalid_focus",
]);
function safeToolActivityCode(error: unknown): string | undefined {
const message = error instanceof Error ? error.message : String(error ?? "");
for (const code of SAFE_TOOL_ACTIVITY_CODES) {
if (message.includes(code)) {
return code === "focus_idempotency_conflict" || code === "invalid_focus"
? "duplicate_focus"
: code === "quote_not_grounded"
? "quote_mismatch"
: code;
}
}
return undefined;
}
export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationActivityEvent | null {
if (chunk.type !== "tool-call" && chunk.type !== "tool-result" && chunk.type !== "tool-error") {
return null;
@@ -196,7 +221,13 @@ export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationAc
return { type: "tool.activity", tool: toolName, status: "started" };
}
if (chunk.type === "tool-error") {
return { type: "tool.activity", tool: toolName, status: "failed" };
const code = safeToolActivityCode(chunk.payload?.error);
return {
type: "tool.activity",
tool: toolName,
status: "failed",
...(code ? { code } : {}),
};
}
const methods = resultMethods(chunk);
return {
@@ -228,6 +259,7 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
activity?: unknown;
questionId?: unknown;
recoverable?: unknown;
origin?: unknown;
};
if (event.type === "thinking.delta") return null;
if (event.type === "error") {
@@ -254,11 +286,15 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
const methods = event.status === "completed" && Array.isArray(event.methods)
? [...new Set(event.methods.filter(isPublicRectificationMethod))]
: [];
const code = typeof event.code === "string" && SAFE_TOOL_ACTIVITY_CODES.has(event.code)
? event.code
: undefined;
return {
type: "tool.activity",
tool: event.tool,
status: event.status,
...(methods.length > 0 ? { methods } : {}),
...(code ? { code } : {}),
};
}
if (event.type === "activity.changed") {
@@ -1153,6 +1153,8 @@ export type V10EvidenceBatchItem = Readonly<{
occurredTo: string | null;
datePrecision: string;
summary: string;
quoteStart?: number | null;
quoteEnd?: number | null;
}>;
export type V10EvidenceBatchResult = Readonly<{
@@ -1186,6 +1188,8 @@ export async function recordV10EvidenceBatch(
sourceTurnId,
JSON.stringify({
quote: item.quote,
quote_start: item.quoteStart ?? null,
quote_end: item.quoteEnd ?? null,
subject: item.subject,
event_kind: item.eventKind,
domain: item.domain,
@@ -0,0 +1,32 @@
import { parseAgentChoiceCopy } from "./choice-card";
import type { V9CaseDossier } from "./tool-service";
export type RectificationNarrationDto = Readonly<{
acknowledgedFacts: readonly string[];
nextQuestion: string | null;
}>;
export function publicNarrationDtoFromDossier(dossier: V9CaseDossier): RectificationNarrationDto {
const acknowledgedFacts = dossier.evidence
.filter((item) => item.status === "confirmed" || item.status === "draft" || item.status === "pending_confirmation")
.slice(-4)
.map((item) => item.summary.trim())
.filter((item) => item.length >= 2);
const choice = parseAgentChoiceCopy(dossier.conversationSummary.activeFocus?.expectedAnswerSchema ?? null);
return {
acknowledgedFacts,
nextQuestion: choice?.prompt ?? null,
};
}
export function composeRectificationTurnNarration(dto: RectificationNarrationDto): string {
const parts: string[] = [];
if (dto.acknowledgedFacts.length > 0) {
parts.push(`已经记下:${dto.acknowledgedFacts.join("")}`);
}
if (dto.nextQuestion) parts.push(dto.nextQuestion);
if (parts.length === 0) {
return "请继续说下一件你记得比较清楚、大概带年份的经历。";
}
return parts.join("");
}