fix(consult): treat pinched answers as failed and restore reply actions

Incomplete Flash generations were billed as completed consultations. Fail
those runs, keep the partial text, and reuse the rectification like/copy/rerun
bar on ordinary chat replies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-19 19:37:02 +08:00
parent c38f11dbd3
commit 7d667fecbf
17 changed files with 487 additions and 89 deletions
+1
View File
@@ -180,6 +180,7 @@ export const logAgentObservability = createAgentObservabilityLogger();
const knownErrorCodes = new Set([
"runtime_contract_incomplete",
"empty_answer",
"answer_truncated",
"calculation_failed",
"timeout",
"cancelled",
@@ -100,7 +100,7 @@ const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt:
// and losing the whole failure event would be worse than losing its receipt.
const runFailedSchema = z.object({
type: z.literal("run.failed"),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "cancelled"]),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "answer_truncated", "cancelled"]),
message: z.string().max(200),
receipt: agentExecutionReceiptSchema.optional(),
}).strict();
+45 -21
View File
@@ -77,6 +77,27 @@ function safeToolError(error: unknown) {
return "calculation_failed" as const;
}
function isTimeoutOrAbort(error: unknown) {
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
}
type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed";
function runFailedCode(error: unknown, emitted: boolean): RunFailedCode {
if (error instanceof Error && error.message === "runtime_contract_incomplete") return "runtime_contract_incomplete";
if (error instanceof Error && error.message === "empty_answer") return "empty_answer";
if (error instanceof Error && error.message === "answer_truncated") return "answer_truncated";
if (emitted && isTimeoutOrAbort(error)) return "answer_truncated";
return "calculation_failed";
}
function runFailedMessage(code: RunFailedCode) {
if (code === "runtime_contract_incomplete") return "Agent 未完成必要的方法与计算步骤,本次不会扣点。";
if (code === "empty_answer") return "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。";
if (code === "answer_truncated") return "回答未完成,已保留现有内容;本次不会扣点。";
return "咨询暂时无法完成,本次不会扣点。";
}
/**
* Whether a tool result is really an input rejection Mastra resolved with.
*
@@ -287,19 +308,26 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// A retry runs a second model loop under the same step budget, so the run
// total accumulates while the finish reason describes the latest attempt.
const stepCountBeforeAttempt = options.state.modelStepCount;
for await (const chunk of readChunks(stream)) {
for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event);
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
if (chunk.type === "finish") {
const finish = finishTelemetry(chunk);
options.state.modelFinishReason = finish.reason;
if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount;
}
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
await outputText(visible.push(chunk.payload.text));
try {
for await (const chunk of readChunks(stream)) {
for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event);
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
if (chunk.type === "finish") {
const finish = finishTelemetry(chunk);
options.state.modelFinishReason = finish.reason;
if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount;
}
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
await outputText(visible.push(chunk.payload.text));
}
}
await outputText(visible.finish(""));
} catch (error) {
try {
await outputText(visible.finish(""));
} catch {}
throw error;
}
await outputText(visible.finish(""));
}
const body = new ReadableStream<Uint8Array>({
@@ -330,6 +358,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// Still nothing to show. Failing is the honest outcome and it is the
// one that does not charge for the run.
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
// A spoken answer that stopped because the token budget ran out is
// not a completed consultation. The heading may already be on screen,
// so keep it and refuse to bill.
if (options.state.modelFinishReason === "length") throw new Error("answer_truncated");
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
await options.onComplete?.(fullOutput, receipt);
@@ -344,11 +376,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
try {
await options.onError?.(error, emitted, fullOutput);
} catch {}
const code = error instanceof Error && error.message === "runtime_contract_incomplete"
? "runtime_contract_incomplete" as const
: error instanceof Error && error.message === "empty_answer"
? "empty_answer" as const
: "calculation_failed" as const;
const code = runFailedCode(error, emitted);
// Step durations, the step budget and the workflow route are the only
// evidence the caller has for why a run failed. Building the receipt
// must not be able to replace the failure event with a silent close.
@@ -359,11 +387,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
send(controller, {
type: "run.failed",
code,
message: code === "runtime_contract_incomplete"
? "Agent 未完成必要的方法与计算步骤,本次不会扣点。"
: code === "empty_answer"
? "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。"
: "咨询暂时无法完成,本次不会扣点。",
message: runFailedMessage(code),
...(failureReceipt ? { receipt: failureReceipt } : {}),
});
if (!disconnected) controller.close();