fix(consult): 降级正文过 Pass 4,去重并停止空跑 compose(BUG-959/960/961)

降级交付复用 releasePass4Sentences;每次 attempt 清空 uncontractedText;成功降级后跳过 compose。
This commit is contained in:
jesse-ux
2026-09-18 18:45:18 +08:00
parent bed961c4a1
commit 5e8b2ac112
6 changed files with 304 additions and 28 deletions
+61 -27
View File
@@ -466,6 +466,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
stream: ChunkStream,
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
) {
// Each attempt owns its own uncontracted buffer. Accumulating across the
// contract retry delivered the first draft and the retry as one answer.
uncontractedText = "";
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
let held = "";
let composingSent = Boolean(attempt.suppressCompositionActivity);
@@ -687,6 +690,46 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
return true;
}
async function deliverDegradedAnswer(
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
) {
const origin = fullOutput;
if (options.pass4Mode) {
await releasePass4Sentences(controller, uncontractedText, false);
await releasePass4Sentences(controller, "", true);
} else if (/\S/.test(uncontractedText)) {
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: uncontractedText });
fullOutput += uncontractedText;
emitted = true;
}
if (!/\S/.test(fullOutput.slice(origin.length))) {
if (options.pass4Mode === "general_no_birth_time") {
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: GENERAL_NO_BIRTH_TIME_REFUSAL });
fullOutput += GENERAL_NO_BIRTH_TIME_REFUSAL;
emitted = true;
} else {
return false;
}
}
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: CONTRACT_DEGRADED_STEP,
status: "failed",
});
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
fullOutput += CONTRACT_DEGRADED_NOTE;
emitted = true;
return true;
}
const body = new ReadableStream<Uint8Array>({
start(controller) {
const sideEvent = options.sideEvent
@@ -700,6 +743,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of skillBoundEvents) send(controller, event);
flushThinkingPlan(controller);
try {
let deliveredDegraded = false;
await consumeAttempt(controller, options.stream, {
drainSpoken: Boolean(options.composeAnswer),
});
@@ -715,21 +759,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
&& options.state.consultationToolSuccessCount === 0
&& /\S/.test(uncontractedText);
if (canDegrade) {
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: CONTRACT_DEGRADED_STEP,
status: "failed",
});
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: uncontractedText });
fullOutput += uncontractedText;
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
fullOutput += CONTRACT_DEGRADED_NOTE;
emitted = true;
} else {
deliveredDegraded = await deliverDegradedAnswer(controller);
}
if (!deliveredDegraded) {
appendConsultationRuntimeStep(options.state, {
kind: "validation",
name: RUNTIME_CONTRACT_INCOMPLETE_STEP,
@@ -738,18 +770,20 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
throw new Error("runtime_contract_incomplete");
}
}
const findings = await publishFindings(controller);
const composed = await composeOnce(controller, findings);
if (!composed) {
await continueCurrentAnswer(controller);
if (options.pass4Mode) await finishPass4(controller, "", findings);
}
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
const retryOrigin = fullOutput;
await consumeAttempt(controller, await options.retryForAnswer());
if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false);
if (!deliveredDegraded) {
const findings = await publishFindings(controller);
const composed = await composeOnce(controller, findings);
if (!composed) {
await continueCurrentAnswer(controller);
if (options.pass4Mode) await finishPass4(controller, "", findings);
}
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
const retryOrigin = fullOutput;
await consumeAttempt(controller, await options.retryForAnswer());
if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false);
}
}
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
settling = true;