feat(consult): classify consultation workflow failures for diagnosis
Independent Staging Quality Gate / validate (push) Failing after 13m12s
Independent Staging Quality Gate / publish (push) Has been skipped

Every workflow fault except abort and timeout collapsed into the single
calculation_failed code, and the upstream message was discarded, so a failing
run left no evidence of whether the API rejected the call or returned a payload
that missed the response contract.

Classify failures into a closed vocabulary carried on ConsultationWorkflowError
and record it as the failureCode of the runtime step. The observability tool
call schema gains one controlled optional field; upstream error text stays out
of logs, as that contract requires. Forward request_id to the API so a run can
be aligned with its access log.

Build public receipts from an explicit allowlist. The internal failure code
must not reach the client contract, whose step schema is strict and would
otherwise reject a successful run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 12:44:19 +08:00
parent 7886629b36
commit 8169bd85f7
6 changed files with 165 additions and 7 deletions
+4 -2
View File
@@ -39,6 +39,7 @@ import {
consultationStepBudgetReceipt,
createConsultationRuntimeHooks,
createConsultationRuntimeState,
publicConsultationRuntimeSteps,
} from "@/mastra/consultation-tools";
import {
applyBirthTimeModeToWorkflowContext,
@@ -549,6 +550,7 @@ export async function POST(request: Request) {
name: step.name,
durationMs: Math.max(0, Math.trunc(step.durationMs ?? 0)),
status: step.status,
...(step.failureCode ? { failureCode: step.failureCode } : {}),
})),
contractPhases: [
{
@@ -652,7 +654,7 @@ export async function POST(request: Request) {
runId: requestId,
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
steps: state.steps,
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: workflowReceipt,
techniqueTruth: generalDailyContext ? "public-panchanga-only" : "not-applicable",
@@ -716,7 +718,7 @@ export async function POST(request: Request) {
runId: requestId,
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
steps: state.steps,
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: state.workflowReceipt ?? workflowReceipt,
techniqueTruth: state.techniqueTruth ?? "unknown",
+2
View File
@@ -72,6 +72,8 @@ export const agentObservabilityToolCallSchema = z.object({
name: machineCodeSchema,
durationMs: durationMsSchema,
status: z.enum(agentObservabilityStepStatuses),
// Closed classification of why the call failed. Never upstream error text.
failureCode: machineCodeSchema.optional(),
}).strict().readonly();
export const agentObservabilityContractPhaseSchema = z.object({
+26 -1
View File
@@ -10,6 +10,7 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati
import type { WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import {
consultationInputSchema,
consultationWorkflowFailureCode,
consultationWorkflowReceipt,
runConsultationWorkflow,
toAgentConsultationContext,
@@ -40,6 +41,7 @@ export type ConsultationRuntimeStep = {
name: string;
status: "completed" | "failed";
durationMs?: number;
failureCode?: string;
};
export type ConsultationRuntimeState = {
@@ -73,6 +75,21 @@ export function createConsultationRuntimeState(options: { plannedSteps?: number;
};
}
/**
* Public receipts carry only the fields the client contract allows. Building
* the list from an explicit allowlist keeps internal diagnostics, such as the
* workflow failure classification, from reaching the response.
*/
export function publicConsultationRuntimeSteps(state: ConsultationRuntimeState) {
return state.steps.map((step) => ({
sequence: step.sequence,
kind: step.kind,
name: step.name,
status: step.status,
...(step.durationMs === undefined ? {} : { durationMs: step.durationMs }),
}));
}
export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit<ConsultationRuntimeStep, "sequence">) {
if (state.steps.length >= state.stepBudget.total) {
state.stepsTruncated = true;
@@ -226,6 +243,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode);
const agentContext = toAgentConsultationContext(guarded);
@@ -249,7 +267,14 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
return toModelDomainPlanContext(executions);
} catch (error) {
ctx.state.consultationToolDurationMs = Date.now() - startedAt;
appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: ctx.state.consultationToolDurationMs });
const failureCode = consultationWorkflowFailureCode(error);
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: "run-jyotish-consultation",
status: "failed",
durationMs: ctx.state.consultationToolDurationMs,
...(failureCode ? { failureCode } : {}),
});
throw error;
}
})();
+66 -3
View File
@@ -45,9 +45,60 @@ function record(value: unknown): JsonRecord {
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
/**
* Closed vocabulary for why a consultation workflow call failed.
*
* The upstream error text is a provider payload and must not reach logs, so
* the failure is classified here into a machine code that observability can
* record without carrying free-form content.
*/
export const consultationWorkflowFailureCodes = [
"workflow_bad_request",
"workflow_forbidden",
"workflow_unsupported_media_type",
"workflow_rate_limited",
"workflow_queue_full",
"workflow_server_error",
"workflow_http_error",
"workflow_empty_response",
"workflow_contract_invalid",
"workflow_timeout",
"workflow_aborted",
"workflow_unreachable",
] as const;
export type ConsultationWorkflowFailureCode = typeof consultationWorkflowFailureCodes[number];
export class ConsultationWorkflowError extends Error {
readonly code: ConsultationWorkflowFailureCode;
constructor(code: ConsultationWorkflowFailureCode, message: string) {
super(message);
this.name = "ConsultationWorkflowError";
this.code = code;
}
}
function httpFailureCode(status: number): ConsultationWorkflowFailureCode {
if (status === 400) return "workflow_bad_request";
if (status === 403) return "workflow_forbidden";
if (status === 415) return "workflow_unsupported_media_type";
if (status === 429) return "workflow_rate_limited";
if (status === 503) return "workflow_queue_full";
if (status >= 500) return "workflow_server_error";
return "workflow_http_error";
}
export function consultationWorkflowFailureCode(error: unknown): ConsultationWorkflowFailureCode | undefined {
if (error instanceof ConsultationWorkflowError) return error.code;
if (error instanceof DOMException && error.name === "TimeoutError") return "workflow_timeout";
if (error instanceof DOMException && error.name === "AbortError") return "workflow_aborted";
return undefined;
}
export async function runConsultationWorkflow(
input: ConsultationInput,
options?: { foreground?: boolean; signal?: AbortSignal; plan?: ConsultationPlan },
options?: { foreground?: boolean; signal?: AbortSignal; plan?: ConsultationPlan; requestId?: string },
) {
const { entryMode, question, theme, ...workflowInput } = input;
const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme });
@@ -59,6 +110,8 @@ export async function runConsultationWorkflow(
headers: { "content-type": "application/json" },
body: JSON.stringify({
...workflowInput,
// Correlates this call with the API access log when a run fails.
...(options?.requestId ? { request_id: options.requestId } : {}),
entry_mode: entryMode,
question: workflowRequest.question,
question_text: workflowRequest.question,
@@ -77,9 +130,19 @@ export async function runConsultationWorkflow(
signal,
});
const data = await response.json().catch(() => null);
if (!response.ok || !data) throw new Error(data?.error || data?.message || `Jyotish API returned ${response.status}`);
if (!response.ok || !data) {
throw new ConsultationWorkflowError(
response.ok ? "workflow_empty_response" : httpFailureCode(response.status),
data?.error || data?.message || `Jyotish API returned ${response.status}`,
);
}
const parsed = consultationWorkflowResponseSchema.safeParse(data);
if (!parsed.success) throw new Error("Jyotish API returned an incomplete consultation contract");
if (!parsed.success) {
throw new ConsultationWorkflowError(
"workflow_contract_invalid",
"Jyotish API returned an incomplete consultation contract",
);
}
return parsed.data;
}