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