import { NextResponse } from "next/server"; import { consultationInputSchema, consultationWorkflowReceipt, getGeneralJyotishAgent, getJyotishAgent, getLegacyJyotishAgent, getWindowJyotishAgent, 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, shouldLoadGeneralDailyPanchanga, } from "@/lib/consultation-entrypoint"; import { CreditRpcError } from "@/lib/consultation-billing"; import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage } from "@/lib/agent-generation-settings"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; 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 { consultationContinuePrompt, consultationSectionPrompt, type PublicThinkingSection } from "@/lib/consultation-thinking-plan"; import { AGENT_MAX_STEPS, AGENT_TIMEOUT_MS, AGENT_SLICE_MAX_STEPS, consultationContinueGenerationSettings, consultationGenerationSettings, consultationSliceGenerationSettings, createConsultationAgentContext, createWindowConsultationAgentContext, consultationModelStepTelemetry, consultationStepBudgetReceipt, createConsultationRuntimeHooks, createConsultationRuntimeState, publicConsultationRuntimeSteps, } from "@/mastra/consultation-tools"; import { applyBirthTimeModeToWorkflowContext, consultationBirthTimeModeSchema, createBirthTimeModeOutputGuard, shouldRunBirthChartWorkflow, shouldRunDeclaredWindowWorkflow, 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 { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history"; 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) .optional() .default([]), }); const chartChatRequestSchema = consultationInputSchema.extend({ ...chatRequestMetadataSchema.shape, consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time", "declared_birth_window"]) .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 windowChatRequestSchema = z.object({ ...chatRequestMetadataSchema.shape, consultationMode: z.literal("declared_birth_window"), question: z.string().trim().min(1).max(500), theme: consultationDomainSchema, entrypoint: z.literal("daily_starlanguage").optional(), }).strict(); const chatRequestSchema = z.union([ generalChatRequestSchema, windowChatRequestSchema, 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(), }); const consultationQuestionAppendSchema = z.object({ success: z.boolean(), error_code: z.string().nullable().optional(), }); function first(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(action: () => Promise): Promise { 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 [ "以下是服务器计算并校验结构后的公共日历证据。只能在其边界内解释,不得补充个人命盘结论。", "", JSON.stringify(context), "", ].join("\n"); } function generalNoMinuteInstruction(hasPublicDaily: boolean) { return hasPublicDaily ? "当前是无出生分钟的公共今日趋势咨询。可依据服务器提供的公共 Panchanga 摘要回答,但不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。" : "当前没有具体出生分钟。不得计算或推断个人星盘,也不得补 00:00、时段中点或任何候选分钟。请直接回答用户的问题:能答的部分照实说,需要出生分钟的部分明确标出限制。不要把整轮对话改成「一般知识或生时校正」二选一,也不要把服务端当前时间当成用户提供的出生时间。"; } function declaredWindowInstruction() { return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。"; } function usesPublicDailyGeneralAgent( consultationMode: ConsultationBirthTimeMode, generalDailyContext: GeneralDailyPanchangaContext | null, ) { return consultationMode === "general_no_birth_time" || (consultationMode === "declared_birth_window" && Boolean(generalDailyContext)); } function chinaCalendarDate(now: Date) { return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); } type Usage = { inputTokens?: number; outputTokens?: number; cache?: ReturnType; }; function mergeUsage(usages: Promise[]): Promise { return Promise.all(usages).then((items) => items.reduce((total, item) => { const usage = item && typeof item === "object" ? item as Record : {}; const cache = promptCacheUsage(usage); return { inputTokens: (total.inputTokens ?? 0) + (typeof usage.inputTokens === "number" ? usage.inputTokens : 0), outputTokens: (total.outputTokens ?? 0) + (typeof usage.outputTokens === "number" ? usage.outputTokens : 0), cache: mergePromptCacheUsage([total.cache, cache]), }; }, {} as Usage)); } function shouldUseAgenticRuntime(user: { id: string; app_metadata?: Record }) { 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>; let accounting: ReturnType; try { supabase = await createServerSupabaseClient(); accounting = createAdminSupabaseClient(); } catch { return NextResponse.json( { error: "服务尚未配置", message: "请先配置数据库环境变量。" }, { 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,messages") .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; // Client `history` stays in the request schema for old bundles and is not read. const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages); const userControlledPrompt = [ parsed.data.question, ...storedHistory.filter((message) => message.role === "user").map((message) => message.text), ].join("\n"); if (blocksPromptExtraction(userControlledPrompt)) { return NextResponse.json( { error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续询问占星相关问题。", }, { status: 400 }, ); } type SelectedModel = NonNullable>>; type ReservationResult = { success: boolean; credits: number | null; error_code: string | null }; type ModelSelection = Awaited>>; let prepared: PreparedConsultationRoute; try { prepared = await prepareConsultationRoute({ 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_period,declared_window_start,declared_window_end,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 pricing = await resolveFeaturePricing(accounting, "chat.standard", model.id); 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: pricing.credit_cost, }); 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 FeaturePricingError) { console.error( `[billing] reservation failed request=${requestId} reason=${error.code}`, ); return NextResponse.json( { error: "计费配置不可用", message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。", code: "pricing_configuration_unavailable", }, { status: 503 }, ); } 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 { 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"; } } let appendedQuestion: { success: boolean; error_code?: string | null }; try { appendedQuestion = await retryDetachedSettlement(async () => { const { data, error } = await accounting.rpc("append_consultation_question", { p_user_id: userId, p_request_id: requestId, p_session_id: sessionId, p_question_message: { role: "user", text: visibleQuestion }, }); const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? [])); if (error || !parsedAppend.success) { throw new CreditRpcError(error?.message || "invalid_question_append_response"); } return parsedAppend.data; }); } catch { await cancel(); return NextResponse.json( { error: "暂时无法保存问题", message: "请稍后重试。" }, { status: 503 }, ); } if (!appendedQuestion.success) { await cancel(); if (appendedQuestion.error_code === "session_full") { return NextResponse.json( { error: "这段对话已写满", message: "这段对话已写满,开个新对话继续吧", code: "session_full", }, { status: 409 }, ); } return NextResponse.json( { error: "暂时无法保存问题", message: "请稍后重试。" }, { status: 503 }, ); } const usageStartedAt = Date.now(); async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) { const resolved = await usage; const usageRecord = resolved as Record; const cache = promptCacheUsage(usageRecord); const inputTokens = Math.max(0, Math.trunc(typeof usageRecord.inputTokens === "number" ? usageRecord.inputTokens : 0)); const outputTokens = Math.max(0, Math.trunc(typeof usageRecord.outputTokens === "number" ? usageRecord.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, ...(cache ? { metadata: { cache: { ...cache, hit: cache.readTokens > 0 } } } : {}), }; } async function completeResponse( rawTransformedText: string, usage: Promise<{ inputTokens?: number; outputTokens?: number }>, techniqueTruth: string, workflowReceipt: WorkflowReceipt, agentExecutionReceipt?: AgentExecutionReceipt, thinkingText?: string, thinkingSections?: PublicThinkingSection[], ): Promise { try { const reply = parseAgentReply( rawTransformedText, createConsultationReplyMetadata({ question: visibleQuestion }), ); if (!reply.text) throw new Error("empty_agent_reply"); const persistedThinking = thinkingText?.trim().slice(0, 4_000); const responseMessage = { role: "assistant" as const, text: reply.text, ...(persistedThinking ? { thinkingText: persistedThinking } : {}), ...(thinkingSections?.length ? { thinkingSections } : {}), 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 | null = null; function settleResult(action: () => Promise) { settlement ??= action(); return settlement; } async function settle(action: () => Promise): Promise { 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 } = {}; 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[] = []; 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.jyotishSkillBound ? "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, 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 cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const baseMessages = [ ...(cacheBoundary ? [cacheBoundary] : []), ...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" || (consultationMode === "declared_birth_window" && generalDailyContext) ? generalNoMinuteInstruction(Boolean(generalDailyContext)) : consultationMode === "declared_birth_window" ? declaredWindowInstruction() : "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。事业/财富/婚恋/家庭按 skill Level 2 模板写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。不要复述内部 JSON 字段。", 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, ...consultationGenerationSettings(selectedModel.model), }; const workflowReceipt: WorkflowReceipt = usesPublicDailyGeneralAgent(consultationMode, generalDailyContext) ? { route: generalDailyContext ? "general-daily-panchanga" : "general-no-birth-time", status: "ready", preciseTiming: "blocked", missingLayers: ["birth-minute"], } : consultationMode === "declared_birth_window" ? { route: "declared-birth-window", status: "blocked", preciseTiming: "blocked", missingLayers: ["birth-minute"], } : { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] }; if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) { state.workflowReceipt = workflowReceipt; const agent = getGeneralJyotishAgent(selectedModel); const result = await agent.stream(baseMessages, streamOptions); usages.push(result.totalUsage); // This mode has no calculation to require and no chart method to bind, // so there is no contract for a retry to repair. const retryForAnswer = async () => { const retried = await agent.stream([ ...baseMessages, { role: "user" as const, content: "上一轮没有输出任何回答文本。请直接给出这个问题的回答,不要只说明过程。" }, ], streamOptions); usages.push(retried.totalUsage); return retried.fullStream; }; const continueAfterLength = async (output: string) => { const continued = await agent.stream([ ...baseMessages, { role: "assistant" as const, content: output }, { role: "user" as const, content: consultationContinuePrompt(output) }, ], { ...streamOptions, ...consultationContinueGenerationSettings(selectedModel.model), }); usages.push(continued.totalUsage); return continued.fullStream; }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillBound, referenceReads: state.skillReferenceReadCount, methodologySections: state.methodologySectionCount, }, steps: publicConsultationRuntimeSteps(state), stepBudget: consultationStepBudgetReceipt(state), workflow: workflowReceipt, techniqueTruth: generalDailyContext ? "public-panchanga-only" : "not-applicable", ...(state.techniqueAuditTable?.length ? { techniqueAuditTable: state.techniqueAuditTable } : {}), }); return streamAgentResponse({ runId: requestId, requestId, state, stream: result.fullStream, requireTool: false, retryForAnswer, continueAfterLength, continueAfterDisconnect: true, transformText: createBirthTimeModeOutputGuard( generalDailyContext ? "general_no_birth_time" : consultationMode, false, ), toolStatus: () => "ready", receipt: executionReceipt, headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), generalDailyContext ? "public-panchanga-only" : "not-applicable", workflowReceipt, agentExecutionReceipt, thinkingText, thinkingSections, ), undefined), onError: (error) => settleRun( cancel, toAgentObservabilityErrorCode(error), ), onCancel: () => settleRun(cancel, "cancelled"), }); } if (shouldRunDeclaredWindowWorkflow(consultationMode)) { if (!prepared.declaredWindow) throw new Error("declared_window_truth_missing"); const agentContext = createWindowConsultationAgentContext({ userId, sessionId, requestId, consultationMode: "declared_birth_window", plan: prepared.preReserveResult, theme: consultationTheme, declaredWindow: prepared.declaredWindow, abortSignal: agentAbortSignal, state, }); const agent = getWindowJyotishAgent(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: "运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答;不要在工具参数中添加出生分钟。", }, ], streamOptions); usages.push(retried.totalUsage); return retried.fullStream; }; const retryForAnswer = async () => { const retried = await agent.stream([ ...baseMessages, { role: "user" as const, content: "服务器窗口计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。", }, ], streamOptions); usages.push(retried.totalUsage); return retried.fullStream; }; const continueAfterLength = async (output: string) => { const continued = await agent.stream([ ...baseMessages, { role: "assistant" as const, content: output }, { role: "user" as const, content: consultationContinuePrompt(output) }, ], { ...streamOptions, ...consultationContinueGenerationSettings(selectedModel.model), }); usages.push(continued.totalUsage); return continued.fullStream; }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillBound, referenceReads: state.skillReferenceReadCount, methodologySections: state.methodologySectionCount, }, steps: publicConsultationRuntimeSteps(state), stepBudget: consultationStepBudgetReceipt(state), workflow: state.workflowReceipt ?? workflowReceipt, techniqueTruth: state.techniqueTruth ?? "declared-window", ...(state.techniqueAuditTable?.length ? { techniqueAuditTable: state.techniqueAuditTable } : {}), }); return streamAgentResponse({ runId: requestId, requestId, state, stream: result.fullStream, requireTool: true, retry, retryForAnswer, continueAfterLength, continueAfterDisconnect: true, transformText: createBirthTimeModeOutputGuard(consultationMode, false), toolStatus: () => workflowStatus(state.workflowReceipt?.status), receipt: executionReceipt, headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "declared-window", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, thinkingText, thinkingSections, ), 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: consultationMode === "verified_chart" ? "verified_chart" : "unverified_birth_time", 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: "运行合同不完整:本次尚未取得服务器计算结果。请调用 run-jyotish-consultation 完成计算,再据此回答;不要在工具参数中添加出生资料。", }, ], streamOptions); usages.push(retried.totalUsage); return retried.fullStream; }; // The tool caches this request's calculation, so this attempt gets the same // evidence back without paying for it twice; keeping the tools available is // what puts that evidence in front of the model at all. const retryForAnswer = async () => { const retried = await agent.stream([ ...baseMessages, { role: "user" as const, content: "服务器计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答;不要只描述过程或工具调用。", }, ], streamOptions); usages.push(retried.totalUsage); return retried.fullStream; }; const continueAfterLength = async (output: string) => { const continued = await agent.stream([ ...baseMessages, { role: "assistant" as const, content: output }, { role: "user" as const, content: consultationContinuePrompt(output) }, ], { ...streamOptions, ...consultationContinueGenerationSettings(selectedModel.model), }); usages.push(continued.totalUsage); return continued.fullStream; }; const composeSection = async (heading: string, priorOutput: string) => { const sliced = await agent.stream([ ...baseMessages, ...(priorOutput.trim() ? [{ role: "assistant" as const, content: priorOutput }] : []), { role: "user" as const, content: consultationSectionPrompt(heading, priorOutput) }, ], { ...streamOptions, maxSteps: AGENT_SLICE_MAX_STEPS, ...consultationSliceGenerationSettings(selectedModel.model), }); usages.push(sliced.totalUsage); return sliced.fullStream; }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillBound, referenceReads: state.skillReferenceReadCount, methodologySections: state.methodologySectionCount, }, steps: publicConsultationRuntimeSteps(state), stepBudget: consultationStepBudgetReceipt(state), workflow: state.workflowReceipt ?? workflowReceipt, techniqueTruth: state.techniqueTruth ?? "unknown", ...(state.techniqueAuditTable?.length ? { techniqueAuditTable: state.techniqueAuditTable } : {}), }); return streamAgentResponse({ runId: requestId, requestId, state, stream: result.fullStream, requireTool: true, retry, retryForAnswer, continueAfterLength, composeSection, 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, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "unknown", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, thinkingText, thinkingSections, ), undefined), onError: (error) => settleRun( cancel, toAgentObservabilityErrorCode(error), ), onCancel: () => settleRun(cancel, "cancelled"), }); } try { const history = storedHistory; const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name; const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode; const generalDailyContext = shouldLoadGeneralDailyPanchanga({ consultationMode, entrypoint: parsed.data.entrypoint, visibleQuestion: parsed.data.question, }) ? await loadGeneralDailyPanchangaContext({ date: currentDate, reference: prepared.generalDailyReference, }) : null; if (shouldUseAgenticRuntime(user) || shouldRunDeclaredWindowWorkflow(consultationMode)) { return await runAgenticConsultation(consultationMode, history, name, generalDailyContext); } if (!shouldRunBirthChartWorkflow(consultationMode)) { const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const result = await getGeneralJyotishAgent(selectedModel).stream([ ...(cacheBoundary ? [cacheBoundary] : []), { role: "user", content: [ currentTimeContext(requestTime), name ? `用户称呼:${name}` : "", generalNoMinuteInstruction(Boolean(generalDailyContext)), 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( generalDailyContext ? "general_no_birth_time" : 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, { birthTimeSource: prepared.serverChart.truth.birthTimeSource }, ); const workflowReceipt = consultationWorkflowReceipt(workflowContext); const cacheBoundary = cachedSystemMessage("【上下文缓存边界】后续内容为本轮请求输入。", selectedModel.model); const result = await getLegacyJyotishAgent(selectedModel, workflowContext).stream([ ...(cacheBoundary ? [cacheBoundary] : []), ...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}` : "", "请按 skill Level 2 模板回答下面的问题:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,然后才是现代生活措辞。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。", resolvedQuestion.modelQuestion, ].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 }, ); } }