fix(consultation): ensure final response after tool completion

This commit is contained in:
Jesse_Chen
2026-08-14 17:02:02 +08:00
parent afc8af5b8e
commit ae4a6e4720
2 changed files with 47 additions and 1 deletions
+21
View File
@@ -32,6 +32,14 @@ async function* readChunks(stream: ChunkStream): AsyncIterable<Chunk> {
}
type Status = "ready" | "degraded" | "blocked";
export const ENSURE_FINAL_RESPONSE_FALLBACK =
"本次计算已完成,但暂时没有生成可展示的回答。请换一个角度提问,我会基于已完成的计算继续说明。";
export function ensureFinalResponseText(output: string, contractIsReady: boolean) {
if (!contractIsReady || /\S/.test(output)) return null;
return ENSURE_FINAL_RESPONSE_FALLBACK;
}
type EventOptions = {
runId: string;
requestId: string;
@@ -202,6 +210,19 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
await consumeAttempt(controller, await options.retry());
}
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
const ensuredFinalResponse = ensureFinalResponseText(fullOutput, contractReady(options));
if (ensuredFinalResponse) {
if (options.state.steps.length < 32) {
options.state.steps.push({ sequence: options.state.steps.length + 1, kind: "validation", name: "ensure-final-response", status: "completed" });
}
if (!firstOutput) {
firstOutput = true;
await options.onFirstOutput?.();
}
send(controller, { type: "answer.delta", text: ensuredFinalResponse });
fullOutput = ensuredFinalResponse;
emitted = true;
}
if (!/\S/.test(fullOutput)) {
if (/\S/.test(first.held) || /\S/.test(first.attemptOutput)) throw new Error("runtime_contract_incomplete");
throw new Error("empty_answer");
@@ -3,7 +3,7 @@ import test from "node:test";
import { createConsultationTools, createConsultationRuntimeState } from "../src/mastra/consultation-tools.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { collectAgentPublicEvents, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
import { collectAgentPublicEvents, ensureFinalResponseText, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
const serverChart = {
name: "测试",
@@ -166,6 +166,31 @@ test("incomplete runtime contract fails without saving a successful answer", asy
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
});
test("ensures a controlled final response after a successful tool-only run", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillLoaded = true;
state.consultationToolCallCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
let completedOutput = "";
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "ready", receipt: () => receipt(state),
onComplete: (output) => { completedOutput = output; },
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(ensureFinalResponseText("", true), completedOutput);
assert.match(completedOutput, /计算已完成/);
assert.equal(events.filter((event) => (event as { type?: string }).type === "answer.delta").length, 1);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
assert.equal(state.steps.at(-1)?.name, "ensure-final-response");
});
test("persistence failure emits run.failed instead of run.completed", async () => {
const state = createConsultationRuntimeState();