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
+2
View File
@@ -37,6 +37,7 @@ import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-
import {
AGENT_MAX_STEPS,
AGENT_TIMEOUT_MS,
consultationGenerationSettings,
createConsultationAgentContext,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
@@ -653,6 +654,7 @@ export async function POST(request: Request) {
maxSteps: AGENT_MAX_STEPS,
abortSignal: agentAbortSignal,
hooks,
...consultationGenerationSettings(selectedModel.model),
};
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
? {
+8 -8
View File
@@ -679,14 +679,14 @@ button:disabled { cursor: default; opacity: .45; }
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
.rectification-message-entry { min-width: 0; }
.rectification-message-actions {
.message-actions {
display: flex;
align-items: center;
gap: 1px;
margin: -4px 0 8px 42px;
color: var(--color-ink-tertiary);
}
.rectification-message-actions button {
.message-actions button {
display: inline-flex;
width: 26px;
height: 26px;
@@ -702,17 +702,17 @@ button:disabled { cursor: default; opacity: .45; }
opacity: .7;
transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out;
}
.rectification-message-actions button:hover:not(:disabled) {
.message-actions button:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent);
color: var(--color-ink-secondary);
opacity: 1;
transform: translateY(-1px);
}
.rectification-message-actions button:active:not(:disabled) { transform: translateY(0); }
.rectification-message-actions button.is-active { color: var(--color-action); opacity: 1; }
.rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; }
.rectification-message-actions button:disabled { cursor: default; opacity: 0.32; }
.rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; }
.message-actions button:active:not(:disabled) { transform: translateY(0); }
.message-actions button.is-active { color: var(--color-action); opacity: 1; }
.message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; }
.message-actions button:disabled { cursor: default; opacity: 0.32; }
.message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; }
.rectification-analysis {
width: min(620px, calc(100% - 42px));
margin: -2px 0 var(--space-1) 42px;
+139 -11
View File
@@ -26,6 +26,11 @@ import {
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import {
ChatMessageActions,
toggleChatMessageFeedback,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { BirthPlacePicker } from "@/components/birth-place-picker";
@@ -1059,6 +1064,8 @@ export default function Home() {
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
@@ -2907,16 +2914,22 @@ export default function Home() {
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
targetSessionId: string | null = null,
options: { resumeRequestId?: string } = {},
options: {
resumeRequestId?: string;
sessionOverride?: ChatSession;
restoreOnFailure?: ChatSession;
} = {},
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
const currentSession = targetSessionId
const liveSession = targetSessionId
? sessions.find((session) => session.id === targetSessionId)
: activeSession;
const currentSession = options.sessionOverride ?? liveSession;
if (!question || !currentSession || !modelCatalog || !account) return false;
const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
const pending = pendingConsultation.current;
@@ -3003,7 +3016,7 @@ export default function Home() {
question: originalQuestion,
entrypoint,
theme,
previousSession: currentSession,
previousSession: rollbackSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
@@ -3077,9 +3090,9 @@ export default function Home() {
await persistSession(userSession);
} catch (caught) {
if (controller.signal.aborted) return false;
updateSession(sessionId, () => currentSession);
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(entrypoint);
@@ -3088,7 +3101,9 @@ export default function Home() {
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。");
setComposerNotice(options.restoreOnFailure
? "问题保存失败,未开始生成;已恢复原来的回答。"
: "问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
@@ -3164,6 +3179,7 @@ export default function Home() {
};
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
let runCompleted = false;
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let answer = "";
@@ -3200,7 +3216,18 @@ export default function Home() {
workflowReceipt = event.receipt.workflow;
techniqueTruth = event.receipt.techniqueTruth ?? "unknown";
}
if (event.type === "run.failed") throw new ConsultationResponseError(502, event.message);
if (event.type === "run.failed") {
if (event.code === "answer_truncated") {
truncatedFailure = event;
if (event.receipt) {
agentExecutionReceipt = event.receipt;
workflowReceipt = event.receipt.workflow;
techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth;
}
return;
}
throw new ConsultationResponseError(502, event.message);
}
updateActivity(event);
});
while (true) {
@@ -3209,7 +3236,38 @@ export default function Home() {
parser.push(decoder.decode(value, { stream: true }));
}
parser.finish(decoder.decode());
if (!runCompleted) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。");
if (truncatedFailure) {
const reply = parseAgentReply(answer);
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
const truncatedSession: ChatSession = {
...userSession,
title: userSession.title,
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => truncatedSession);
setStreamingReply(null);
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(truncatedFailure.message);
try {
await persistSession(truncatedSession);
} catch (error) {
setRequestError({
sessionId,
message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。",
});
}
completeConsultationInterface(requestId);
void refreshAccount();
return true;
}
if (!runCompleted && !truncatedFailure) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。");
} else {
while (true) {
const { done, value } = await reader.read();
@@ -3264,6 +3322,11 @@ export default function Home() {
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
const restore = options.restoreOnFailure;
if (restore) {
updateSession(sessionId, () => restore);
void persistSession(restore).catch(() => {});
}
completeConsultationInterface(requestId);
return false;
}
@@ -3299,6 +3362,42 @@ export default function Home() {
}
}
async function copyAssistantMessage(renderKey: string, text: string) {
try {
await navigator.clipboard.writeText(text);
setCopiedMessageKey(renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
function regenerateLatestAnswer(renderKey: string) {
const session = activeSession;
if (!session || isLoading || cancellationPending || pendingConsultation.current) return;
const last = session.messages.at(-1);
if (last?.role !== "assistant" || last.text.trim() === "") return;
const previous = session.messages.at(-2);
if (previous?.role !== "user" || previous.text.trim() === "") return;
if (`message-${session.messages.length - 1}` !== renderKey) return;
const sessionOverride: ChatSession = {
...session,
messages: session.messages.slice(0, -1),
updatedAt: timestamp(),
};
setMessageFeedback((current) => {
const next = { ...current };
delete next[`${session.id}:${renderKey}`];
return next;
});
void send(previous.text, session.theme, null, null, session.id, {
sessionOverride,
restoreOnFailure: session,
});
}
consultationReplay.current = () => {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return;
@@ -3636,9 +3735,38 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
<ChatMessageRow key={message.renderKey} message={message} />
))}
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const feedbackKey = `${activeSession.id}:${message.renderKey}`;
const latestRegeneratableKey = !isLoading && !cancellationPending
? [...views].reverse().find((item) => (
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
))?.renderKey
: undefined;
return (
<div className="message-entry" key={message.renderKey}>
<ChatMessageRow message={message} />
{showActions && (
<ChatMessageActions
feedback={messageFeedback[feedbackKey]}
copied={copiedMessageKey === feedbackKey}
canRegenerate={message.renderKey === latestRegeneratableKey}
onFeedback={(requested) => setMessageFeedback((current) => {
const next = { ...current };
const value = toggleChatMessageFeedback(current[feedbackKey], requested);
if (value) next[feedbackKey] = value;
else delete next[feedbackKey];
return next;
})}
onCopy={() => void copyAssistantMessage(feedbackKey, message.text)}
onRegenerate={() => regenerateLatestAnswer(message.renderKey)}
/>
)}
</div>
);
})}
{activeError && <p className="error-message" role="alert">{activeError}</p>}
</div>
)}
@@ -0,0 +1,65 @@
"use client";
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
export type ChatMessageFeedback = "up" | "down";
export function toggleChatMessageFeedback(
current: ChatMessageFeedback | undefined,
requested: ChatMessageFeedback,
): ChatMessageFeedback | undefined {
return current === requested ? undefined : requested;
}
export function ChatMessageActions({
feedback,
copied = false,
canRegenerate,
onFeedback,
onCopy,
onRegenerate,
}: Readonly<{
feedback?: ChatMessageFeedback;
copied?: boolean;
canRegenerate: boolean;
onFeedback: (value: ChatMessageFeedback) => void;
onCopy: () => void;
onRegenerate: () => void;
}>) {
return (
<div className="message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback === "up"}
className={feedback === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => onFeedback("up")}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback === "down"}
className={feedback === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => onFeedback("down")}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={onCopy}>
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</button>
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
type="button"
onClick={onRegenerate}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
@@ -23,6 +23,10 @@ import {
} from "@/lib/rectification-agentic/v9/public-receipt";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import {
ChatMessageActions,
toggleChatMessageFeedback,
} from "./chat-message-actions";
import { CompletedActivityReceipt } from "./completed-activity-receipt";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
@@ -125,7 +129,7 @@ export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return current === requested ? undefined : requested;
return toggleChatMessageFeedback(current, requested);
}
function activityPhase(tool: PublicRectificationTool): NonNullable<ChatMessageView["activity"]>["phase"] {
@@ -584,48 +588,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
/>
)}
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
type="button"
onClick={() => void regenerateMessage(message)}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
<ChatMessageActions
feedback={feedback[message.renderKey]}
copied={copiedMessageKey === message.renderKey}
canRegenerate={canRegenerate}
onFeedback={(requested) => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
}))}
onCopy={() => void copyMessage(message)}
onRegenerate={() => void regenerateMessage(message)}
/>
)}
</div>
);
+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();
+28
View File
@@ -42,6 +42,7 @@ import {
// calculation inside the 110s budget—so it is measured, not guessed.
export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;
const CONSULTATION_DOMAIN_DURATION_MS = 21_000;
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
@@ -50,6 +51,33 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS),
);
const thinkingDisabled = { thinking: { type: "disabled" as const } };
/**
* Visible-answer generation settings for one consult stream.
*
* 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.
*/
export function consultationGenerationSettings(model?: unknown) {
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,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
return {
modelSettings: { maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS },
providerOptions,
};
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
// canonicalizes instead of failing outright. The executable cap is enforced
// after canonicalization, where it can degrade and disclose rather than throw.