feat(consultation): let the jyotish agent drive skills and tools
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
consultationWorkflowReceipt,
|
||||
getGeneralJyotishAgent,
|
||||
getJyotishAgent,
|
||||
getLegacyJyotishAgent,
|
||||
runConsultationWorkflow,
|
||||
} from "@/mastra";
|
||||
import { blocksPromptExtraction } from "@/lib/consult-safety";
|
||||
@@ -18,6 +19,13 @@ 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 {
|
||||
createConsultationAgentContext,
|
||||
createConsultationRuntimeHooks,
|
||||
createConsultationRuntimeState,
|
||||
} from "@/mastra/consultation-tools";
|
||||
import {
|
||||
applyBirthTimeModeToWorkflowContext,
|
||||
consultationBirthTimeModeSchema,
|
||||
@@ -32,7 +40,7 @@ import {
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
export const maxDuration = 120;
|
||||
|
||||
const chatRequestMetadataSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
@@ -117,6 +125,29 @@ 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() ?? "legacy";
|
||||
if (mode === "enabled") return true;
|
||||
if (mode !== "canary") return false;
|
||||
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>;
|
||||
@@ -228,7 +259,10 @@ export async function POST(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
let prepared;
|
||||
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: Awaited<ReturnType<typeof prepareConsultationRoute<ModelSelection>>>;
|
||||
try {
|
||||
prepared = await prepareConsultationRoute({
|
||||
userId,
|
||||
@@ -367,12 +401,8 @@ export async function POST(request: Request) {
|
||||
rawTransformedText: string,
|
||||
usage: Promise<{ inputTokens?: number; outputTokens?: number }>,
|
||||
techniqueTruth: string,
|
||||
workflowReceipt: {
|
||||
route: string;
|
||||
status: string;
|
||||
preciseTiming: string;
|
||||
missingLayers: readonly string[];
|
||||
},
|
||||
workflowReceipt: WorkflowReceipt,
|
||||
agentExecutionReceipt?: AgentExecutionReceipt,
|
||||
) {
|
||||
try {
|
||||
const reply = parseAgentReply(rawTransformedText, consultationTheme);
|
||||
@@ -383,6 +413,7 @@ export async function POST(request: Request) {
|
||||
suggestions: reply.suggestions,
|
||||
techniqueTruth,
|
||||
workflowReceipt,
|
||||
...(agentExecutionReceipt ? { agentExecutionReceipt } : {}),
|
||||
};
|
||||
const actualUsage = await usagePayload(usage);
|
||||
const completion = await retryDetachedSettlement(async () => {
|
||||
@@ -414,10 +445,199 @@ export async function POST(request: Request) {
|
||||
return settlement;
|
||||
}
|
||||
|
||||
async function runAgenticConsultation(
|
||||
consultationMode: ConsultationBirthTimeMode,
|
||||
history: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
name: string,
|
||||
) {
|
||||
const state = createConsultationRuntimeState();
|
||||
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 = (finishReason: string, settlementResult: string) => {
|
||||
if (logged) return;
|
||||
logged = true;
|
||||
console.info([
|
||||
"[consult-agentic]",
|
||||
`request_id=${requestId}`,
|
||||
`run_id=${requestId}`,
|
||||
`session_id=${sessionId}`,
|
||||
`model_id=${selectedModel.id}`,
|
||||
`skill_loaded=${state.jyotishSkillLoaded}`,
|
||||
`skill_reference_read_count=${state.skillReferenceReadCount}`,
|
||||
`consultation_tool_call_count=${state.consultationToolCallCount}`,
|
||||
`consultation_tool_duration_ms=${state.consultationToolDurationMs ?? -1}`,
|
||||
`time_to_first_activity_ms=${firstActivityMs}`,
|
||||
`time_to_first_text_ms=${firstTextMs}`,
|
||||
`total_duration_ms=${Date.now() - agentStartedAt}`,
|
||||
`finish_reason=${finishReason}`,
|
||||
`settlement_result=${settlementResult}`,
|
||||
].join(" "));
|
||||
};
|
||||
const settleRun = async (action: () => Promise<void>, finishReason: string, settlementResult: string) => {
|
||||
try {
|
||||
await settle(action);
|
||||
logRun(finishReason, settlementResult);
|
||||
} catch (error) {
|
||||
logRun("settlement_failed", "failed");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
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"
|
||||
? "当前是用户明确选择的无出生分钟一般咨询。不得计算或推断个人星盘;不得补 00:00、时段中点或任何候选分钟。"
|
||||
: "先加载 Jyotish Skill;如需新的个人星盘结论,必须调用服务器绑定的排盘工具。",
|
||||
resolvedQuestion.modelQuestion,
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
];
|
||||
const agentAbortSignal = AbortSignal.timeout(110_000);
|
||||
const streamOptions = {
|
||||
runId: requestId,
|
||||
maxSteps: 6,
|
||||
abortSignal: agentAbortSignal,
|
||||
hooks,
|
||||
};
|
||||
const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time"
|
||||
? { route: "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: state.steps,
|
||||
workflow: workflowReceipt,
|
||||
techniqueTruth: "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),
|
||||
"not-applicable",
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
), "completed", "completed"),
|
||||
onError: (error) => settleRun(
|
||||
cancel,
|
||||
error instanceof Error ? error.message : "failed",
|
||||
"cancelled",
|
||||
),
|
||||
onCancel: () => settleRun(cancel, "cancelled", "cancelled"),
|
||||
});
|
||||
}
|
||||
|
||||
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
|
||||
const agentContext = createConsultationAgentContext({
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
consultationMode,
|
||||
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: state.steps,
|
||||
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,
|
||||
), "completed", "completed"),
|
||||
onError: (error) => settleRun(
|
||||
cancel,
|
||||
error instanceof Error ? error.message : "failed",
|
||||
"cancelled",
|
||||
),
|
||||
onCancel: () => settleRun(cancel, "cancelled", "cancelled"),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { history } = parsed.data;
|
||||
const name = prepared.serverChart?.name ?? parsed.data.name;
|
||||
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
|
||||
if (shouldUseAgenticRuntime(user)) {
|
||||
return await runAgenticConsultation(consultationMode, history, name);
|
||||
}
|
||||
if (!shouldRunBirthChartWorkflow(consultationMode)) {
|
||||
const result = await getGeneralJyotishAgent(selectedModel).stream([
|
||||
{
|
||||
@@ -430,12 +650,12 @@ export async function POST(request: Request) {
|
||||
].filter(Boolean).join("\n"),
|
||||
},
|
||||
]);
|
||||
const workflowReceipt = {
|
||||
const workflowReceipt: WorkflowReceipt = {
|
||||
route: "general-no-birth-time",
|
||||
status: "ready",
|
||||
preciseTiming: "blocked",
|
||||
missingLayers: ["birth-minute"],
|
||||
} as const;
|
||||
};
|
||||
const settleErrored = (emitted: boolean, output: string) => settle(
|
||||
emitted
|
||||
? () => completeResponse(
|
||||
@@ -485,7 +705,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
const workflowReceipt = consultationWorkflowReceipt(workflowContext);
|
||||
|
||||
const result = await getJyotishAgent(selectedModel, workflowContext).stream([
|
||||
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 }),
|
||||
|
||||
Reference in New Issue
Block a user