fix(consult): 第 0 步改回 auto,供应商 error 块不再被吞掉
BUG-937 撤回 thinking 模式下的 required toolChoice,只留 activeTools。BUG-938 让咨询流识别 Mastra error 块,公开码 calculation_failed,内部码进日志且不触发合同 retry。
This commit is contained in:
@@ -185,6 +185,8 @@ const knownErrorCodes = new Set([
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"settlement_failed",
|
||||
"thinking_tool_choice_unsupported",
|
||||
"provider_error",
|
||||
]);
|
||||
|
||||
export function toAgentObservabilityErrorCode(error: unknown): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type PublicThinkingSection,
|
||||
} from "./consultation-thinking-plan.ts";
|
||||
|
||||
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown };
|
||||
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown; error?: unknown };
|
||||
type ChunkStream = AsyncIterable<unknown> | ReadableStream<unknown>;
|
||||
|
||||
async function* readChunks(stream: ChunkStream): AsyncIterable<Chunk> {
|
||||
@@ -90,11 +90,33 @@ function isTimeoutOrAbort(error: unknown) {
|
||||
}
|
||||
|
||||
type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed";
|
||||
type ProviderStreamErrorCode = "thinking_tool_choice_unsupported" | "provider_error";
|
||||
|
||||
function providerErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function classifyProviderStreamError(error: unknown): ProviderStreamErrorCode {
|
||||
return providerErrorMessage(error).includes("Thinking mode does not support this tool_choice")
|
||||
? "thinking_tool_choice_unsupported"
|
||||
: "provider_error";
|
||||
}
|
||||
|
||||
function chunkProviderError(chunk: Chunk): unknown {
|
||||
if (chunk.error !== undefined) return chunk.error;
|
||||
return chunk.payload?.error;
|
||||
}
|
||||
|
||||
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 (
|
||||
error instanceof Error
|
||||
&& (error.message === "thinking_tool_choice_unsupported" || error.message === "provider_error")
|
||||
) {
|
||||
return "calculation_failed";
|
||||
}
|
||||
if (emitted && isTimeoutOrAbort(error)) return "answer_truncated";
|
||||
return "calculation_failed";
|
||||
}
|
||||
@@ -383,6 +405,21 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
const stepCountBeforeAttempt = options.state.modelStepCount;
|
||||
try {
|
||||
for await (const chunk of readChunks(stream)) {
|
||||
if (chunk.type === "error") {
|
||||
const raw = chunkProviderError(chunk);
|
||||
const code = classifyProviderStreamError(raw);
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: "model-stream-error",
|
||||
status: "failed",
|
||||
});
|
||||
console.error("[consult-provider-error]", {
|
||||
requestId: options.requestId,
|
||||
code,
|
||||
messageHead: providerErrorMessage(raw).slice(0, 200),
|
||||
});
|
||||
throw new Error(code);
|
||||
}
|
||||
for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event);
|
||||
flushThinkingPlan(controller);
|
||||
if (chunk.type === "step-finish") options.state.modelStepCount += 1;
|
||||
|
||||
@@ -60,10 +60,11 @@ export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
|
||||
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
|
||||
|
||||
/**
|
||||
* Step 0 must call the natal chart tool. Mastra/AI SDK types accept
|
||||
* toolChoice "required" (`@mastra/core` Agent.stream options:
|
||||
* `'auto' | 'none' | 'required'`). Later steps stay auto so the model can
|
||||
* write after the request-scoped calculation is in hand.
|
||||
* Step 0 exposes only the natal chart tool. Some thinking-mode providers
|
||||
* reject named and required toolChoice (BUG-282, BUG-937); the first step
|
||||
* therefore narrows with activeTools and leaves the choice as auto. Later
|
||||
* steps stay auto so the model can write after the request-scoped
|
||||
* calculation is in hand.
|
||||
*
|
||||
* Window and general agents must not share this hook: they do not own this tool.
|
||||
*/
|
||||
@@ -71,7 +72,7 @@ export function consultationNatalPrepareStep(input: { stepNumber: number }) {
|
||||
return input.stepNumber === 0
|
||||
? {
|
||||
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
|
||||
toolChoice: "required" as const,
|
||||
toolChoice: "auto" as const,
|
||||
}
|
||||
: {
|
||||
toolChoice: "auto" as const,
|
||||
@@ -82,7 +83,7 @@ export function consultationWindowPrepareStep(input: { stepNumber: number }) {
|
||||
return input.stepNumber === 0
|
||||
? {
|
||||
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
|
||||
toolChoice: "required" as const,
|
||||
toolChoice: "auto" as const,
|
||||
}
|
||||
: {
|
||||
toolChoice: "auto" as const,
|
||||
|
||||
@@ -149,6 +149,14 @@ test("error normalization never records arbitrary exception messages", () => {
|
||||
toAgentObservabilityErrorCode(new Error("answer_truncated")),
|
||||
"answer_truncated",
|
||||
);
|
||||
assert.equal(
|
||||
toAgentObservabilityErrorCode(new Error("provider_error")),
|
||||
"provider_error",
|
||||
);
|
||||
assert.equal(
|
||||
toAgentObservabilityErrorCode(new Error("thinking_tool_choice_unsupported")),
|
||||
"thinking_tool_choice_unsupported",
|
||||
);
|
||||
assert.equal(
|
||||
toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")),
|
||||
"calculation_failed",
|
||||
|
||||
@@ -656,11 +656,11 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
|
||||
|
||||
test("natal first step exposes only the chart calculation tool", () => {
|
||||
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
|
||||
// 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto"
|
||||
// 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删
|
||||
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
|
||||
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-consultation"],
|
||||
toolChoice: "required",
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
@@ -669,9 +669,11 @@ test("natal first step exposes only the chart calculation tool", () => {
|
||||
|
||||
test("window first step requires the window consultation tool", () => {
|
||||
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
|
||||
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
|
||||
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-window-consultation"],
|
||||
toolChoice: "required",
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
@@ -1266,6 +1268,79 @@ 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("a thinking-mode toolChoice rejection fails without a contract retry (BUG-938)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let onErrorMessage = "";
|
||||
async function* chunks() {
|
||||
yield { type: "error", error: new Error("Thinking mode does not support this tool_choice") };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
toolStatus: () => "blocked",
|
||||
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
retry: async () => {
|
||||
assert.fail("provider error must not trigger a contract retry");
|
||||
return chunks();
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMessage = error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
|
||||
assert.deepEqual(events.map((event) => (event as { type: string }).type), [
|
||||
"run.started",
|
||||
"skill.started",
|
||||
"skill.completed",
|
||||
"run.failed",
|
||||
]);
|
||||
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
code: string;
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
assert.equal(failure.code, "calculation_failed");
|
||||
assert.equal(onErrorMessage, "thinking_tool_choice_unsupported");
|
||||
assert.equal(
|
||||
events.some((event) => (event as { type?: string; phase?: string }).type === "activity"
|
||||
&& (event as { phase?: string }).phase === "loading-method"),
|
||||
false,
|
||||
);
|
||||
assert.ok(failure.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "model-stream-error" && step.status === "failed"));
|
||||
assert.doesNotMatch(JSON.stringify(events), /Thinking mode does not support this tool_choice/);
|
||||
});
|
||||
|
||||
test("a generic provider stream error is calculation_failed and skips retry (BUG-938)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let onErrorMessage = "";
|
||||
async function* chunks() {
|
||||
yield { type: "error", error: new Error("upstream 502") };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
toolStatus: () => "blocked",
|
||||
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
retry: async () => {
|
||||
assert.fail("provider error must not trigger a contract retry");
|
||||
return chunks();
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMessage = error instanceof Error ? error.message : String(error);
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
|
||||
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string };
|
||||
assert.equal(failure.code, "calculation_failed");
|
||||
assert.equal(onErrorMessage, "provider_error");
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
|
||||
assert.doesNotMatch(JSON.stringify(events), /upstream 502/);
|
||||
});
|
||||
|
||||
function toolOnlyRunState() {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.jyotishSkillBound = true;
|
||||
|
||||
@@ -26,22 +26,29 @@ test("natal and window instructions require a chart tool every turn (BUG-922)",
|
||||
});
|
||||
|
||||
test("natal and window first steps require the chart tool (BUG-923)", () => {
|
||||
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
|
||||
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-consultation"],
|
||||
toolChoice: "required",
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-window-consultation"],
|
||||
toolChoice: "required",
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
test("consultation prepareStep never sends required or named toolChoice (BUG-282 / BUG-937)", () => {
|
||||
assert.doesNotMatch(tools, /toolChoice:\s*"required"/);
|
||||
assert.doesNotMatch(tools, /type:\s*"tool",\s*toolName/);
|
||||
});
|
||||
|
||||
test("consultation plans are server-owned and bounded", () => {
|
||||
assert.match(plan, /consultationPlanSchema/);
|
||||
assert.match(plan, /requestedDomains/);
|
||||
|
||||
Reference in New Issue
Block a user