Files
Jyotisha/frontend/src/app/api/consult/route.ts
T
Jesse_Chen 1955ba8cef fix(consult): give a multi-domain plan a top-level answer contract it can obey
A staging consultation submitted a three-domain plan, calculated all three
successfully in 62.9s, and returned nothing but the ensureFinalResponseText
fallback. The step budget was barely touched, so this is not the exhaustion
c8d9ec64 fixed. toModelDomainPlanContext returns two different shapes: a single
domain flattens the evidence packet to the top level, several domains return only
success, domains and consultations. Every hard output rule in jyotishInstructions
is written against those top-level paths — evidence_contract.answer_policy,
hard_blockers, rectification.boundary, status. None of them resolve in the
multi-domain shape, and under a policy that forbids stating anything the server
evidence does not support, silence is what the instructions ask for.

Merge the packets into one top-level contract shaped exactly like the single
domain one. Merging may only restrict: status takes the worst of ready >
degraded > blocked, hard_blockers and missing_route_layers take the union,
permission booleans need every domain to agree while limitation booleans need
only one, and a field the domains genuinely disagree on is reported as
unresolved rather than decided. available_layers is the one permission-shaped
union, because a layer really was computed for some domain and denying it would
deny real evidence. The natal projection is the same chart for every domain, so
it is hoisted to one copy when the domains agree and left per-domain when they
do not.

The domain cap was six, advertised as six, and could never be paid for. Domains
run sequentially at ~21s each against a cumulative 110s abort signal, so six is
~126s and four leaves nothing to write the answer with. Concurrency is not
available: the Python API is a single GIL-bound ThreadingHTTPServer whose async
work already sits behind a two-worker bounded queue that answers 503 when full.
Derive the cap from the clock instead of choosing it — 110s minus a 45s answer
reserve, divided by 21s, is three — and let the model-facing schema carry that
bound so an unpayable plan is unrepresentable. A caller that builds a plan
without that schema is truncated rather than refused, the loop stops early when
the measured pace says the next domain will not fit, and either way the dropped
domains are disclosed through omitted_domains and the receipt while status
degrades, so a partial answer cannot be presented as complete.

run.failed carried a code and nothing else, so the step durations, step budget
and workflow route recorded by c8d9ec64 were unavailable exactly when a run
needed explaining. Send the same allowlisted receipt run.completed sends,
built through publicConsultationRuntimeSteps so the internal failure code and
model loop diagnostics stay server-side, and never let building it replace the
failure event with a silent close. An agentic run that fails before
streamAgentResponse exists never reached the settle-and-log path either, so the
request-level catch now goes through the same entry point.

Refs BUG-256, BUG-257, BUG-258.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 17:05:56 +08:00

949 lines
36 KiB
TypeScript

import { NextResponse } from "next/server";
import {
consultationInputSchema,
consultationWorkflowReceipt,
getGeneralJyotishAgent,
getJyotishAgent,
getLegacyJyotishAgent,
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
import {
ConsultationPlanValidationError,
createConsultationPlan,
type ConsultationPlan,
} from "@/lib/consultation-plan";
import {
logAgentObservability,
settlementTelemetryOutcome,
toAgentObservabilityErrorCode,
type AgentSettlementResult,
} from "@/lib/agent-observability";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} from "@/lib/consultation-entrypoint";
import { CreditRpcError } from "@/lib/consultation-billing";
import { reserveConsultationModel } from "@/lib/consultation-model-selection";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import {
AGENT_MAX_STEPS,
AGENT_TIMEOUT_MS,
createConsultationAgentContext,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
createConsultationRuntimeHooks,
createConsultationRuntimeState,
publicConsultationRuntimeSteps,
} from "@/mastra/consultation-tools";
import {
applyBirthTimeModeToWorkflowContext,
consultationBirthTimeModeSchema,
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
type ConsultationBirthTimeMode,
} from "@/lib/consultation-birth-time-mode";
import {
ConsultationProfileTruthError,
prepareConsultationRoute,
type PreparedConsultationRoute,
} from "@/lib/consultation-route-service";
import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { z } from "zod";
export const runtime = "nodejs";
export const maxDuration = 120;
// The step budget, the wall-clock budget and the domain cap all bound this same
// run, so they are declared as one group in @/mastra/consultation-tools with the
// reasoning that ties them together. maxDuration above is the ceiling they must
// stay under; raising it here without raising that is meaningless.
const chatRequestMetadataSchema = z.object({
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64),
name: z.string().trim().max(80).optional().default(""),
history: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
text: z.string().max(4000),
}),
)
.max(20)
.default([]),
});
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
}).strict();
const generalChatRequestSchema = z.object({
...chatRequestMetadataSchema.shape,
consultationMode: z.literal("general_no_birth_time"),
question: z.string().trim().min(1).max(500),
theme: consultationDomainSchema,
entrypoint: z.literal("daily_starlanguage").optional(),
}).strict();
const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
const consultationReservationSchema = z.object({
success: z.boolean(),
reservation_id: z.string().uuid().nullable(),
source: z.enum(["subscription", "credits"]).nullable(),
credits: z.number().int().nullable(),
subscription_id: z.string().uuid().nullable(),
reason: z.string().nullable(),
retry_after_seconds: z.number().int().nullable(),
});
const consultationCompletionSchema = z.object({
success: z.boolean(),
credits: z.number().int().nullable(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
const detachedSettlementAttempts = 3;
// ponytail: Staging MVP ceiling—without a queue/worker, detached settlement gets only three short retries.
async function retryDetachedSettlement<T>(action: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= detachedSettlementAttempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
if (attempt < detachedSettlementAttempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
}
throw lastError;
}
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
.replace("T", " ")
.slice(0, 19);
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
function generalDailyContextPrompt(context: GeneralDailyPanchangaContext | null) {
if (!context) return "";
return [
"以下是服务器计算并校验结构后的公共日历证据。只能在其边界内解释,不得补充个人命盘结论。",
"<public-daily-panchanga>",
JSON.stringify(context),
"</public-daily-panchanga>",
].join("\n");
}
function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
type Usage = { inputTokens?: number; outputTokens?: number };
function mergeUsage(usages: Promise<Usage>[]): Promise<Usage> {
return Promise.all(usages).then((items) => items.reduce((total, item) => ({
inputTokens: (total.inputTokens ?? 0) + (item.inputTokens ?? 0),
outputTokens: (total.outputTokens ?? 0) + (item.outputTokens ?? 0),
}), {} as Usage));
}
function shouldUseAgenticRuntime(user: { id: string; app_metadata?: Record<string, unknown> }) {
const mode = process.env.CONSULTATION_AGENTIC_RUNTIME?.trim().toLowerCase() ?? "enabled";
if (mode === "legacy") return false;
if (mode === "enabled") return true;
if (mode !== "canary") return true;
const ids = new Set((process.env.CONSULTATION_AGENTIC_CANARY_USER_IDS ?? "")
.split(",").map((value) => value.trim()).filter(Boolean));
const roles = Array.isArray(user.app_metadata?.roles) ? user.app_metadata.roles : [];
return ids.has(user.id) || user.app_metadata?.role === "admin" || roles.includes("admin");
}
function workflowStatus(status: string | undefined): "ready" | "degraded" | "blocked" {
return status === "ready" || status === "degraded" ? status : "blocked";
}
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession || chatSession.session_type !== "consultation") {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
{
error: "旧版生时校正入口已停用",
message: "请从首页生时校正卡片开始或继续对话式校正,本次不会扣点。",
},
{ status: 409 },
);
}
const requestTime = new Date();
const currentDate = chinaCalendarDate(requestTime);
const userId = user.id;
const requestId = parsed.data.requestId;
const sessionId = parsed.data.sessionId;
const consultationTheme = parsed.data.theme;
const visibleQuestion = parsed.data.question;
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
.filter((message) => message.role === "user")
.map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
{
error: "无法处理该请求",
message:
"我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。",
},
{ status: 400 },
);
}
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
mode: parsed.data.consultationMode,
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
.select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", profileUserId)
.single();
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
return data;
},
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
}),
reserve: () => reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
const { data, error } = await accounting.rpc("reserve_consultation_usage", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_requested_model_id: model.id,
p_credit_cost: model.creditCost,
});
const reservation = consultationReservationSchema.safeParse(first(data ?? []));
if (error || !reservation.success) {
throw new CreditRpcError(error?.message || "invalid_reservation_response");
}
return {
success: reservation.data.success,
credits: reservation.data.credits,
error_code: reservation.data.reason,
};
},
),
});
} catch (error) {
if (error instanceof ConsultationPlanValidationError) {
return NextResponse.json(
{
error: "当前咨询计划不可用",
message: error.code === "plan_cost_ceiling_exceeded"
? "当前会话模型超出本次咨询的点数上限,请选择标准模型后重试,本次不会扣点。"
: "当前咨询模式与回答精度边界不一致,请刷新后重试,本次不会扣点。",
},
{ status: 409 },
);
}
if (error instanceof ConsultationProfileTruthError) {
const modeChanged = error.code === "mode_changed";
return NextResponse.json(
modeChanged
? {
error: "出生时间状态已经变化",
message: "请刷新后重新选择使用填报时间、一般咨询或先完成校正,本次不会扣点。",
}
: {
error: "暂时无法核对完整出生资料",
message: "出生日期、时间来源或出生地点资料不完整或不一致,请重新保存后再试,本次不会扣点。",
},
{ status: modeChanged ? 409 : 503 },
);
}
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[billing] reservation failed request=${requestId} reason=${reason}`,
);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
const resolvedQuestion = resolveConsultationQuestion({
visibleQuestion: parsed.data.question,
entrypoint: parsed.data.entrypoint,
currentDate,
consultationMode: prepared.consultationMode,
});
const modelSelection = prepared.reservation;
if (modelSelection.status === "unavailable") {
return NextResponse.json(
{
error: "模型暂不可用",
message: "请选择其他模型后重新发送,本次不会扣除点数。",
},
{ status: 409 },
);
}
const selectedModel = modelSelection.model;
const reserveResult = modelSelection.reservation;
if (!reserveResult.success) {
const insufficient = reserveResult.error_code === "insufficient_credits";
return NextResponse.json(
{
error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数",
message: insufficient
? "请先兑换咨询点数后再继续。"
: reserveResult.error_code || "请稍后重试。",
},
{ status: insufficient ? 402 : 503 },
);
}
async function cancel(): Promise<AgentSettlementResult> {
try {
const result = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("cancel_consultation_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const parsedCancellation = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCancellation.success) {
throw new CreditRpcError(error?.message || "invalid_cancellation_response");
}
return parsedCancellation.data;
});
if (!result.success && result.error_code !== "request_completed") {
throw new CreditRpcError(result.error_code || "cancellation_rejected");
}
return "cancelled";
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[billing] cancellation failed request=${requestId} reason=${reason}`,
);
return "failed";
}
}
const usageStartedAt = Date.now();
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
const inputTokens = Math.max(0, Math.trunc(resolved.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolved.outputTokens ?? 0));
return {
eventKey: requestId,
actualModelId: selectedModel.id,
modelConfigVersion: selectedModel.configVersion,
inputTokens,
outputTokens,
costMicrousd: Math.round((
inputTokens * (selectedModel.inputCostMicrousdPerMillion ?? 0)
+ outputTokens * (selectedModel.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000),
durationMs: Date.now() - usageStartedAt,
};
}
async function completeResponse(
rawTransformedText: string,
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
techniqueTruth: string,
workflowReceipt: WorkflowReceipt,
agentExecutionReceipt?: AgentExecutionReceipt,
): Promise<AgentSettlementResult> {
try {
const reply = parseAgentReply(
rawTransformedText,
consultationTheme,
createConsultationReplyMetadata({ theme: consultationTheme, question: visibleQuestion }),
);
if (!reply.text) throw new Error("empty_agent_reply");
const responseMessage = {
role: "assistant" as const,
text: reply.text,
suggestions: reply.suggestions,
techniqueTruth,
workflowReceipt,
...(agentExecutionReceipt ? { agentExecutionReceipt } : {}),
};
const actualUsage = await usagePayload(usage);
const completion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("complete_consultation_response", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_response_message: responseMessage,
p_actual_usage: actualUsage,
});
const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? []));
if (error || !parsedCompletion.success) {
throw new CreditRpcError(error?.message || "invalid_completion_response");
}
return parsedCompletion.data;
});
if (!completion.success && completion.error_code !== "request_cancelled") {
throw new CreditRpcError(completion.error_code || "completion_rejected");
}
return "completed";
} catch (error) {
await cancel();
throw error;
}
}
let settlement: Promise<AgentSettlementResult> | null = null;
function settleResult(action: () => Promise<AgentSettlementResult>) {
settlement ??= action();
return settlement;
}
async function settle(action: () => Promise<AgentSettlementResult>): Promise<void> {
await settleResult(action);
}
// A run that fails before streamAgentResponse exists never reaches its error
// path, so the closed observability event—the only place the tool failure
// code, per-step durations and the model finish reason are recorded—would be
// lost for exactly the runs that failed hardest. The agentic setup publishes
// its settle-and-log entry point here so the request-level catch below can
// still emit it.
const agenticFailure: { report?: (error: unknown) => Promise<void> } = {};
async function runAgenticConsultation(
consultationMode: ConsultationBirthTimeMode,
history: Array<{ role: "user" | "assistant"; text: string }>,
name: string,
generalDailyContext: GeneralDailyPanchangaContext | null,
) {
// The recorded step list has to be able to hold everything the model loop
// can produce, otherwise a run that exhausts its steps also truncates the
// evidence of having done so.
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
const agentStartedAt = Date.now();
let firstActivityMs = -1;
let firstTextMs = -1;
let logged = false;
const markFirstActivity = () => { if (firstActivityMs < 0) firstActivityMs = Date.now() - agentStartedAt; };
const markFirstText = () => { if (firstTextMs < 0) firstTextMs = Date.now() - agentStartedAt; };
const logRun = async (
errorCode: string | undefined,
settlementResult: AgentSettlementResult,
) => {
if (logged) return;
logged = true;
const resolvedUsage: Usage = await mergeUsage(usages).catch(() => ({}));
const inputTokens = Math.max(0, Math.trunc(resolvedUsage.inputTokens ?? 0));
const outputTokens = Math.max(0, Math.trunc(resolvedUsage.outputTokens ?? 0));
const skillStep = state.steps.find((step) => step.kind === "skill");
const runStatus = errorCode === undefined
? "completed"
: errorCode === "cancelled"
? "cancelled"
: "failed";
logAgentObservability({
runId: requestId,
requestId,
sessionId,
agentVersion: "consultation-agentic-v1",
modelVersion: String(selectedModel.configVersion),
policyVersion: "consultation-runtime-contract-v1",
toolCalls: state.steps
.filter((step) => step.kind === "tool")
.map((step) => ({
name: step.name,
durationMs: Math.max(0, Math.trunc(step.durationMs ?? 0)),
status: step.status,
...(step.failureCode ? { failureCode: step.failureCode } : {}),
})),
contractPhases: [
{
phase: "skill.load",
...(skillStep?.durationMs === undefined
? {}
: { durationMs: Math.max(0, Math.trunc(skillStep.durationMs)) }),
status: state.jyotishSkillLoaded ? "completed" : "failed",
},
{
phase: "answer.first_activity",
...(firstActivityMs < 0 ? {} : { durationMs: firstActivityMs }),
status: firstActivityMs < 0 ? "skipped" : "completed",
},
{
phase: "answer.first_output",
...(firstTextMs < 0 ? {} : { durationMs: firstTextMs }),
status: firstTextMs < 0 ? "skipped" : "completed",
},
{
phase: "run.total",
durationMs: Date.now() - agentStartedAt,
status: runStatus,
},
{
phase: "billing.settled",
status: settlementResult,
},
],
retryCount: Math.max(0, usages.length - 1),
...consultationModelStepTelemetry(state),
...(errorCode === undefined ? {} : { errorCode }),
inputTokens,
outputTokens,
themeCoverage: state.workflowReceipt?.domains ?? [consultationTheme],
billingSettlementResult: settlementResult,
});
};
const settleRun = async (
action: () => Promise<AgentSettlementResult>,
errorCode: string | undefined,
) => {
try {
const actualSettlementResult = await settleResult(action);
const outcome = settlementTelemetryOutcome(actualSettlementResult, errorCode);
await logRun(outcome.errorCode, outcome.billingSettlementResult);
} catch (error) {
const outcome = settlementTelemetryOutcome("failed", errorCode);
await logRun(outcome.errorCode, outcome.billingSettlementResult);
throw error;
}
};
agenticFailure.report = async (error) => {
try {
await settleRun(cancel, toAgentObservabilityErrorCode(error));
} catch {
// Settlement already reported itself through logRun; the request-level
// failure response must not depend on it succeeding.
}
};
const baseMessages = [
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user" as const,
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
consultationMode === "general_no_birth_time"
? generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
];
const agentAbortSignal = AbortSignal.timeout(AGENT_TIMEOUT_MS);
const streamOptions = {
runId: requestId,
maxSteps: AGENT_MAX_STEPS,
abortSignal: agentAbortSignal,
hooks,
};
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
? {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
}
: { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] };
if (consultationMode === "general_no_birth_time") {
state.workflowReceipt = workflowReceipt;
const agent = getGeneralJyotishAgent(selectedModel);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
{ role: "user" as const, content: "运行合同不完整:请先调用 skill({ name: \"jyotish-vedic-astrology\" }) 加载方法,再回答问题。" },
], streamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: workflowReceipt,
techniqueTruth: generalDailyContext ? "public-panchanga-only" : "not-applicable",
});
return streamAgentResponse({
runId: requestId,
requestId,
state,
stream: result.fullStream,
requireTool: false,
retry,
continueAfterDisconnect: true,
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
toolStatus: () => "ready",
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
agentExecutionReceipt,
), undefined),
onError: (error) => settleRun(
cancel,
toAgentObservabilityErrorCode(error),
),
onCancel: () => settleRun(cancel, "cancelled"),
});
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const agentContext = createConsultationAgentContext({
userId,
sessionId,
requestId,
consultationMode,
plan: prepared.preReserveResult,
theme: consultationTheme,
serverChart: prepared.serverChart,
abortSignal: agentAbortSignal,
state,
});
const agent = getJyotishAgent(selectedModel, agentContext);
const result = await agent.stream(baseMessages, streamOptions);
usages.push(result.totalUsage);
const retry = async () => {
const retried = await agent.stream([
...baseMessages,
{
role: "user" as const,
content: "运行合同不完整:请先加载 jyotish-vedic-astrology Skill,再调用 run-jyotish-consultation 完成服务器计算;不要在工具参数中添加出生资料。",
},
], streamOptions);
usages.push(retried.totalUsage);
return retried.fullStream;
};
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
steps: publicConsultationRuntimeSteps(state),
stepBudget: consultationStepBudgetReceipt(state),
workflow: state.workflowReceipt ?? workflowReceipt,
techniqueTruth: state.techniqueTruth ?? "unknown",
});
return streamAgentResponse({
runId: requestId,
requestId,
state,
stream: result.fullStream,
requireTool: true,
retry,
continueAfterDisconnect: true,
transformText: (text) => createBirthTimeModeOutputGuard(
consultationMode,
state.workflowReceipt?.preciseTiming === "allowed",
)(text),
toolStatus: () => workflowStatus(state.workflowReceipt?.status),
receipt: executionReceipt,
headers: { "x-jyotish-birth-time-mode": consultationMode },
onFirstActivity: markFirstActivity,
onFirstOutput: markFirstText,
onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse(
output,
mergeUsage(usages),
state.techniqueTruth ?? "unknown",
state.workflowReceipt ?? workflowReceipt,
agentExecutionReceipt,
), undefined),
onError: (error) => settleRun(
cancel,
toAgentObservabilityErrorCode(error),
),
onCancel: () => settleRun(cancel, "cancelled"),
});
}
try {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = consultationMode === "general_no_birth_time"
&& parsed.data.entrypoint === "daily_starlanguage"
? await loadGeneralDailyPanchangaContext({
date: currentDate,
reference: prepared.generalDailyReference,
})
: null;
if (shouldUseAgenticRuntime(user)) {
return await runAgenticConsultation(consultationMode, history, name, generalDailyContext);
}
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
generalDailyContext
? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
: "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。",
generalDailyContextPrompt(generalDailyContext),
resolvedQuestion.modelQuestion,
].filter(Boolean).join("\n"),
},
]);
const workflowReceipt: WorkflowReceipt = {
route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time",
status: "ready",
preciseTiming: "blocked",
missingLayers: ["birth-minute"],
};
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(consultationMode, false),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": generalDailyContext ? "public-panchanga-only" : "not-applicable",
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers.join(","),
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
generalDailyContext ? "public-panchanga-only" : "not-applicable",
workflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// A user-reported concrete minute is a normal chart calculation. Keep its
// provenance, but let server evidence—not rectification purchase state—own
// precise-timing permission. Never reactivate the retired questionnaire.
entryMode: "direct_chart",
question: resolvedQuestion.modelQuestion,
theme: parsed.data.theme,
});
const workflowContext = applyBirthTimeModeToWorkflowContext(
await runConsultationWorkflow(toolInput, {
foreground: true,
plan: prepared.preReserveResult,
}),
consultationMode,
);
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
const result = await getLegacyJyotishAgent(selectedModel, workflowContext).stream([
...history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
name ? `用户称呼:${name}` : "",
resolvedQuestion.modelQuestion,
"\n需要查询星盘时,使用以下经过服务端校验的工具参数:",
JSON.stringify(toolInput),
].filter(Boolean).join("\n"),
},
]);
const responseWorkflowReceipt = {
route: workflowReceipt.route,
status: workflowReceipt.status,
preciseTiming: workflowReceipt.preciseTiming,
missingLayers: workflowReceipt.missingLayers === "none"
? []
: workflowReceipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean),
};
const settleErrored = (emitted: boolean, output: string) => settle(
emitted
? () => completeResponse(
output,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)
: cancel,
);
return streamTextResponse(result.textStream, {
transformText: createBirthTimeModeOutputGuard(
consultationMode,
workflowReceipt.preciseTiming !== "blocked",
),
mode: "mastra",
requestId,
continueAfterDisconnect: true,
headers: {
"x-jyotish-workflow-route": workflowReceipt.route,
"x-jyotish-workflow-status": workflowReceipt.status,
"x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
"x-jyotish-precise-timing": workflowReceipt.preciseTiming,
"x-jyotish-missing-layers": workflowReceipt.missingLayers,
"x-jyotish-birth-time-mode": consultationMode,
},
onComplete: (rawTransformedText) => settle(() => completeResponse(
rawTransformedText,
result.totalUsage,
workflowReceipt.techniqueTruth,
responseWorkflowReceipt,
)),
onError: (_error, emitted, output: string) => settleErrored(emitted, output),
onCancel: () => settle(cancel),
});
} catch (error) {
if (agenticFailure.report) await agenticFailure.report(error);
else await cancel();
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(
`[consult] generation failed request=${requestId} model=${selectedModel.id} reason=${reason}`,
);
return NextResponse.json(
{
error: "暂时无法生成解读",
message: "咨询服务暂时不可用,请稍后再试。",
recovery: "稍后重试,或换一个模型继续。",
},
{ status: 503 },
);
}
}