fix(consult): 申报时段计算改为服务端预跑并走同请求缓存(BUG-957)
窗口计算挂在 agent context 缓存上,模型开口前预跑并注入 packet;工具再调用命中同请求缓存,成功次数仍为 1。
This commit is contained in:
@@ -38,7 +38,7 @@ import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
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 { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stream-agent-response";
|
||||
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
|
||||
import { consultationComposePrompt, consultationContinuePrompt, natalConsultationThinkingPlan, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
||||
import {
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
consultationWindowPrepareStep,
|
||||
createConsultationAgentContext,
|
||||
createWindowConsultationAgentContext,
|
||||
precomputeWindowConsultation,
|
||||
windowPrecomputedPacketMessage,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
createConsultationRuntimeHooks,
|
||||
@@ -1039,7 +1041,6 @@ export async function POST(request: Request) {
|
||||
state,
|
||||
});
|
||||
const agent = getWindowJyotishAgent(selectedModel, agentContext);
|
||||
const result = await streamWithOverflowRetry(agent, windowStreamOptions);
|
||||
const retry = async () => {
|
||||
const retried = await agent.stream([
|
||||
...baseMessages,
|
||||
@@ -1096,7 +1097,32 @@ export async function POST(request: Request) {
|
||||
requestId,
|
||||
sideEvent: titleSideEvent,
|
||||
state,
|
||||
stream: result.fullStream,
|
||||
warmup: async (send) => {
|
||||
try {
|
||||
const packet = await precomputeWindowConsultation(agentContext, {
|
||||
question: resolvedQuestion.modelQuestion,
|
||||
writer: {
|
||||
custom: async (chunk) => {
|
||||
if (!chunk || typeof chunk !== "object") return;
|
||||
const value = chunk as { type?: unknown; data?: unknown };
|
||||
if (value.type !== "data-jyotish-activity") return;
|
||||
const event = consultationPublicActivityEvent(value.data);
|
||||
if (event) send(event);
|
||||
},
|
||||
},
|
||||
});
|
||||
baseMessages = [
|
||||
...baseMessages,
|
||||
{ role: "user" as const, content: windowPrecomputedPacketMessage(packet) },
|
||||
];
|
||||
} catch {
|
||||
// Leave the contract red; the model may still call the tool.
|
||||
}
|
||||
},
|
||||
stream: async () => {
|
||||
const streamed = await streamWithOverflowRetry(agent, windowStreamOptions);
|
||||
return streamed.fullStream;
|
||||
},
|
||||
requireTool: true,
|
||||
retry,
|
||||
retryForAnswer,
|
||||
|
||||
@@ -61,7 +61,9 @@ type EventOptions = {
|
||||
state?: ConsultationRuntimeState;
|
||||
};
|
||||
|
||||
function activity(value: unknown): ConsultationAgentPublicEvent | null {
|
||||
export function consultationPublicActivityEvent(
|
||||
value: unknown,
|
||||
): Extract<ConsultationAgentPublicEvent, { type: "activity" }> | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const data = value as { phase?: unknown; label?: unknown };
|
||||
const phase = publicActivityPhaseSchema.safeParse(data.phase);
|
||||
@@ -224,7 +226,7 @@ function mapChunk(
|
||||
): ConsultationAgentPublicEvent[] {
|
||||
const payload = chunk.payload ?? {};
|
||||
if (chunk.type === "data-jyotish-activity") {
|
||||
const event = activity(chunk.data);
|
||||
const event = consultationPublicActivityEvent(chunk.data);
|
||||
return event ? [event] : [];
|
||||
}
|
||||
if (chunk.type === "tool-call") {
|
||||
@@ -311,9 +313,16 @@ export type ThinkFinding = Readonly<{
|
||||
text?: string;
|
||||
}>;
|
||||
|
||||
type AgentStreamSource = ChunkStream | (() => ChunkStream | Promise<ChunkStream>);
|
||||
|
||||
async function resolveAgentStream(stream: AgentStreamSource): Promise<ChunkStream> {
|
||||
return typeof stream === "function" ? await stream() : stream;
|
||||
}
|
||||
|
||||
type StreamAgentResponseOptions = EventOptions & {
|
||||
state: ConsultationRuntimeState;
|
||||
stream: ChunkStream;
|
||||
stream: AgentStreamSource;
|
||||
warmup?: (send: (event: ConsultationAgentPublicEvent) => void) => Promise<void>;
|
||||
transformText?: (text: string) => string;
|
||||
requireTool: boolean;
|
||||
retry?: () => Promise<ChunkStream>;
|
||||
@@ -744,7 +753,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
flushThinkingPlan(controller);
|
||||
try {
|
||||
let deliveredDegraded = false;
|
||||
await consumeAttempt(controller, options.stream, {
|
||||
if (options.warmup) {
|
||||
await options.warmup((event) => send(controller, event));
|
||||
flushThinkingPlan(controller);
|
||||
}
|
||||
await consumeAttempt(controller, await resolveAgentStream(options.stream), {
|
||||
drainSpoken: Boolean(options.composeAnswer),
|
||||
});
|
||||
if (!contractReady(options) && options.retry) {
|
||||
|
||||
@@ -297,6 +297,10 @@ export type ConsultationAgentContext = Readonly<{
|
||||
now?: () => number;
|
||||
}>;
|
||||
|
||||
export type WindowConsultationCalculationCache = {
|
||||
current: Promise<Record<string, unknown>> | null;
|
||||
};
|
||||
|
||||
export type WindowConsultationAgentContext = Readonly<{
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
@@ -310,14 +314,41 @@ export type WindowConsultationAgentContext = Readonly<{
|
||||
now?: () => number;
|
||||
fetchWindowChart?: typeof fetchDeclaredWindowChart;
|
||||
runRangeReading?: typeof runV9RangeReading;
|
||||
calculationCache?: WindowConsultationCalculationCache;
|
||||
}>;
|
||||
|
||||
export function createConsultationAgentContext(context: ConsultationAgentContext) {
|
||||
return Object.freeze(context);
|
||||
}
|
||||
|
||||
export function createWindowConsultationAgentContext(context: WindowConsultationAgentContext) {
|
||||
return Object.freeze(context);
|
||||
export function createWindowConsultationAgentContext(
|
||||
context: Omit<WindowConsultationAgentContext, "calculationCache"> & {
|
||||
calculationCache?: WindowConsultationCalculationCache;
|
||||
},
|
||||
) {
|
||||
return Object.freeze({
|
||||
...context,
|
||||
calculationCache: context.calculationCache ?? { current: null },
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWindowCalculationCache(ctx: WindowConsultationAgentContext): WindowConsultationCalculationCache {
|
||||
if (ctx.calculationCache) return ctx.calculationCache;
|
||||
const created: WindowConsultationCalculationCache = { current: null };
|
||||
(ctx as { calculationCache: WindowConsultationCalculationCache }).calculationCache = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
type WindowToolExecuteContext = {
|
||||
writer?: { custom?: (value: unknown) => unknown };
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export const WINDOW_PRECOMPUTED_PACKET_LEAD =
|
||||
"服务器已完成本轮声明窗口计算。请根据下面的结果回答;不要把探针时刻写成出生分钟。如仍调用 run-jyotish-window-consultation,会命中同请求缓存,不会再算一次。";
|
||||
|
||||
export function windowPrecomputedPacketMessage(packet: Record<string, unknown>): string {
|
||||
return `${WINDOW_PRECOMPUTED_PACKET_LEAD}\n${JSON.stringify(packet)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -757,145 +788,161 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
return { "run-jyotish-consultation": consultationTool };
|
||||
}
|
||||
|
||||
async function executeWindowConsultation(
|
||||
ctx: WindowConsultationAgentContext,
|
||||
input: { question: string },
|
||||
context: WindowToolExecuteContext,
|
||||
) {
|
||||
const cache = ensureWindowCalculationCache(ctx);
|
||||
if (cache.current) return cache.current;
|
||||
ctx.state.consultationToolStarted = true;
|
||||
ctx.state.consultationToolCallCount += 1;
|
||||
const now = ctx.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
const currentCalculation = (async () => {
|
||||
try {
|
||||
await context.writer?.custom?.({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: "正在比较声明出生窗口内的稳定层",
|
||||
},
|
||||
});
|
||||
const packet = await (ctx.fetchWindowChart ?? fetchDeclaredWindowChart)({
|
||||
window: ctx.declaredWindow,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
});
|
||||
let minuteSensitiveThemes: string[] = [];
|
||||
try {
|
||||
const reading = await (ctx.runRangeReading ?? runV9RangeReading)({
|
||||
baselineBirthSnapshot: {
|
||||
birth_date: ctx.declaredWindow.truth.birthDate,
|
||||
latitude: ctx.declaredWindow.truth.latitude,
|
||||
longitude: ctx.declaredWindow.truth.longitude,
|
||||
timezone_offset: ctx.declaredWindow.truth.timezoneOffset,
|
||||
ayanamsa: ctx.declaredWindow.toolInput.ayanamsa,
|
||||
},
|
||||
candidateRange: {
|
||||
start_time: ctx.declaredWindow.toolInput.rangeStart,
|
||||
end_time: ctx.declaredWindow.toolInput.rangeEnd,
|
||||
},
|
||||
birthTimeAccuracy: "approximate",
|
||||
});
|
||||
minuteSensitiveThemes = [...(reading?.sensitiveThemes ?? [])];
|
||||
} catch {
|
||||
minuteSensitiveThemes = [];
|
||||
}
|
||||
const varyingLagna = Array.isArray(packet.varying_layers.ascendant_signs)
|
||||
&& packet.varying_layers.ascendant_signs.length > 1;
|
||||
ctx.state.workflowReceipt = {
|
||||
route: "declared-birth-window",
|
||||
status: packet.answer_policy.can_answer_direction ? "degraded" : "blocked",
|
||||
preciseTiming: "blocked",
|
||||
missingLayers: [
|
||||
...packet.blocked_layers,
|
||||
...(varyingLagna ? ["single-lagna"] : []),
|
||||
],
|
||||
...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}),
|
||||
};
|
||||
ctx.state.techniqueTruth = "declared-window";
|
||||
ctx.state.consultationToolDurationMs = now() - startedAt;
|
||||
await context.writer?.custom?.({
|
||||
type: "data-jyotish-activity",
|
||||
data: { phase: "evidence-validation", label: "正在核对窗口稳定层" },
|
||||
});
|
||||
ctx.state.consultationToolCompleted = true;
|
||||
ctx.state.consultationToolSuccessCount += 1;
|
||||
appendConsultationRuntimeStep(ctx.state, {
|
||||
kind: "tool",
|
||||
name: "run-jyotish-window-consultation",
|
||||
status: "completed",
|
||||
durationMs: ctx.state.consultationToolDurationMs,
|
||||
});
|
||||
const modelContext = {
|
||||
question: ctx.plan?.userIntent ?? input.question,
|
||||
theme: ctx.theme ?? ctx.plan?.requestedDomains[0],
|
||||
declared_range: packet.declared_range,
|
||||
probe_count: packet.probe_count,
|
||||
probes: packet.probes,
|
||||
stable_layers: packet.stable_layers,
|
||||
varying_layers: packet.varying_layers,
|
||||
blocked_layers: packet.blocked_layers,
|
||||
answer_policy: {
|
||||
...packet.answer_policy,
|
||||
minute_sensitive_themes: minuteSensitiveThemes,
|
||||
},
|
||||
status: ctx.state.workflowReceipt.status,
|
||||
evidence_contract: {
|
||||
answer_policy: {
|
||||
...packet.answer_policy,
|
||||
minute_sensitive_themes: minuteSensitiveThemes,
|
||||
},
|
||||
hard_blockers: packet.blocked_layers,
|
||||
user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。这只是粗看。",
|
||||
},
|
||||
rectification: { boundary: "not_auto_rectified" },
|
||||
};
|
||||
ctx.state.thinkingPlan = windowConsultationThinkingPlan();
|
||||
ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows([
|
||||
{
|
||||
technique: "Declared birth window probes",
|
||||
status: "executed",
|
||||
note: `${packet.probe_count} probes inside ${packet.declared_range.start}–${packet.declared_range.end}`,
|
||||
},
|
||||
{
|
||||
technique: "Stable planet signs",
|
||||
status: Object.keys(packet.stable_layers.planet_signs).length > 0 ? "executed" : "blocked",
|
||||
},
|
||||
{
|
||||
technique: "Lagna / houses",
|
||||
status: varyingLagna ? "blocked" : packet.stable_layers.ascendant_sign ? "executed" : "blocked",
|
||||
},
|
||||
{
|
||||
technique: "Vimshottari / Narayana boundaries",
|
||||
status: "blocked",
|
||||
note: "Window probes are not a birth minute",
|
||||
},
|
||||
]);
|
||||
return modelContext;
|
||||
} catch (error) {
|
||||
ctx.state.consultationToolDurationMs = now() - startedAt;
|
||||
appendConsultationRuntimeStep(ctx.state, {
|
||||
kind: "tool",
|
||||
name: "run-jyotish-window-consultation",
|
||||
status: "failed",
|
||||
durationMs: ctx.state.consultationToolDurationMs,
|
||||
failureCode: consultationToolFailureCode(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
cache.current = currentCalculation;
|
||||
try {
|
||||
return await currentCalculation;
|
||||
} catch (error) {
|
||||
if (cache.current === currentCalculation) cache.current = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function precomputeWindowConsultation(
|
||||
ctx: WindowConsultationAgentContext,
|
||||
options: { question: string; writer?: WindowToolExecuteContext["writer"] },
|
||||
) {
|
||||
return executeWindowConsultation(ctx, { question: options.question }, {
|
||||
writer: options.writer,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
export function createWindowConsultationTools(ctx: WindowConsultationAgentContext) {
|
||||
let calculation: Promise<Record<string, unknown>> | null = null;
|
||||
const windowTool = createTool({
|
||||
id: "run-jyotish-window-consultation",
|
||||
description: "Load the server-owned declared birth-window evidence packet. Send only the question. Birth data is server-bound and must never be supplied. Probe clocks are samples, never a birth minute. The packet's answer_policy is the output contract: can_answer_precise_timing is always false; only stable_layers may be claimed as personal structure; varying_layers must be named as a set of possibilities.",
|
||||
inputSchema: z.object({
|
||||
question: z.string().trim().min(1).max(500),
|
||||
}).strict(),
|
||||
execute: async (input, context) => {
|
||||
if (calculation) return calculation;
|
||||
ctx.state.consultationToolStarted = true;
|
||||
ctx.state.consultationToolCallCount += 1;
|
||||
const now = ctx.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
const currentCalculation = (async () => {
|
||||
try {
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: "正在比较声明出生窗口内的稳定层",
|
||||
},
|
||||
});
|
||||
const packet = await (ctx.fetchWindowChart ?? fetchDeclaredWindowChart)({
|
||||
window: ctx.declaredWindow,
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
});
|
||||
let minuteSensitiveThemes: string[] = [];
|
||||
try {
|
||||
const reading = await (ctx.runRangeReading ?? runV9RangeReading)({
|
||||
baselineBirthSnapshot: {
|
||||
birth_date: ctx.declaredWindow.truth.birthDate,
|
||||
latitude: ctx.declaredWindow.truth.latitude,
|
||||
longitude: ctx.declaredWindow.truth.longitude,
|
||||
timezone_offset: ctx.declaredWindow.truth.timezoneOffset,
|
||||
ayanamsa: ctx.declaredWindow.toolInput.ayanamsa,
|
||||
},
|
||||
candidateRange: {
|
||||
start_time: ctx.declaredWindow.toolInput.rangeStart,
|
||||
end_time: ctx.declaredWindow.toolInput.rangeEnd,
|
||||
},
|
||||
birthTimeAccuracy: "approximate",
|
||||
});
|
||||
minuteSensitiveThemes = [...(reading?.sensitiveThemes ?? [])];
|
||||
} catch {
|
||||
minuteSensitiveThemes = [];
|
||||
}
|
||||
const varyingLagna = Array.isArray(packet.varying_layers.ascendant_signs)
|
||||
&& packet.varying_layers.ascendant_signs.length > 1;
|
||||
ctx.state.workflowReceipt = {
|
||||
route: "declared-birth-window",
|
||||
status: packet.answer_policy.can_answer_direction ? "degraded" : "blocked",
|
||||
preciseTiming: "blocked",
|
||||
missingLayers: [
|
||||
...packet.blocked_layers,
|
||||
...(varyingLagna ? ["single-lagna"] : []),
|
||||
],
|
||||
...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}),
|
||||
};
|
||||
ctx.state.techniqueTruth = "declared-window";
|
||||
ctx.state.consultationToolDurationMs = now() - startedAt;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: { phase: "evidence-validation", label: "正在核对窗口稳定层" },
|
||||
});
|
||||
ctx.state.consultationToolCompleted = true;
|
||||
ctx.state.consultationToolSuccessCount += 1;
|
||||
appendConsultationRuntimeStep(ctx.state, {
|
||||
kind: "tool",
|
||||
name: "run-jyotish-window-consultation",
|
||||
status: "completed",
|
||||
durationMs: ctx.state.consultationToolDurationMs,
|
||||
});
|
||||
const modelContext = {
|
||||
question: ctx.plan?.userIntent ?? input.question,
|
||||
theme: ctx.theme ?? ctx.plan?.requestedDomains[0],
|
||||
declared_range: packet.declared_range,
|
||||
probe_count: packet.probe_count,
|
||||
probes: packet.probes,
|
||||
stable_layers: packet.stable_layers,
|
||||
varying_layers: packet.varying_layers,
|
||||
blocked_layers: packet.blocked_layers,
|
||||
answer_policy: {
|
||||
...packet.answer_policy,
|
||||
minute_sensitive_themes: minuteSensitiveThemes,
|
||||
},
|
||||
status: ctx.state.workflowReceipt.status,
|
||||
evidence_contract: {
|
||||
answer_policy: {
|
||||
...packet.answer_policy,
|
||||
minute_sensitive_themes: minuteSensitiveThemes,
|
||||
},
|
||||
hard_blockers: packet.blocked_layers,
|
||||
user_facing_limitation: "这是声明出生窗口内的稳定结构,不是单一出生分钟的本命盘。这只是粗看。",
|
||||
},
|
||||
rectification: { boundary: "not_auto_rectified" },
|
||||
};
|
||||
ctx.state.thinkingPlan = windowConsultationThinkingPlan();
|
||||
ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows([
|
||||
{
|
||||
technique: "Declared birth window probes",
|
||||
status: "executed",
|
||||
note: `${packet.probe_count} probes inside ${packet.declared_range.start}–${packet.declared_range.end}`,
|
||||
},
|
||||
{
|
||||
technique: "Stable planet signs",
|
||||
status: Object.keys(packet.stable_layers.planet_signs).length > 0 ? "executed" : "blocked",
|
||||
},
|
||||
{
|
||||
technique: "Lagna / houses",
|
||||
status: varyingLagna ? "blocked" : packet.stable_layers.ascendant_sign ? "executed" : "blocked",
|
||||
},
|
||||
{
|
||||
technique: "Vimshottari / Narayana boundaries",
|
||||
status: "blocked",
|
||||
note: "Window probes are not a birth minute",
|
||||
},
|
||||
]);
|
||||
return modelContext;
|
||||
} catch (error) {
|
||||
ctx.state.consultationToolDurationMs = now() - startedAt;
|
||||
appendConsultationRuntimeStep(ctx.state, {
|
||||
kind: "tool",
|
||||
name: "run-jyotish-window-consultation",
|
||||
status: "failed",
|
||||
durationMs: ctx.state.consultationToolDurationMs,
|
||||
failureCode: consultationToolFailureCode(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
calculation = currentCalculation;
|
||||
try {
|
||||
return await currentCalculation;
|
||||
} catch (error) {
|
||||
if (calculation === currentCalculation) calculation = null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
execute: async (input, context) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext),
|
||||
});
|
||||
return { "run-jyotish-window-consultation": windowTool };
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ This request has a declared birth window, not a single birth minute. Never inven
|
||||
${jyotishSkillMethodCoreBlock}
|
||||
The bound skill method is this product's answering contract. Window answers do not use the natal Level 2 report skeleton; the window output contract below takes priority over any report-template or precise-timing language in the bound method.
|
||||
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
|
||||
If this turn already includes a server-owned window packet, use it and answer; you may still call the tool, which hits the same-request cache.
|
||||
Timing questions still require calling the tool first. Answer from stable_layers as directional structure, and name which parts need a birth minute. Do not skip the calculation or refuse the whole question because precise timing is unavailable.
|
||||
Treat the tool result's answer_policy as a hard output contract:
|
||||
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
createConsultationRuntimeHooks,
|
||||
createConsultationTools,
|
||||
createConsultationRuntimeState,
|
||||
createWindowConsultationAgentContext,
|
||||
createWindowConsultationTools,
|
||||
precomputeWindowConsultation,
|
||||
domainFitsRunBudget,
|
||||
executableDomainPlan,
|
||||
publicConsultationRuntimeSteps,
|
||||
@@ -41,6 +44,7 @@ import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/l
|
||||
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
|
||||
import {
|
||||
collectAgentPublicEvents,
|
||||
consultationPublicActivityEvent,
|
||||
CONTRACT_DEGRADED_NOTE,
|
||||
streamAgentResponse,
|
||||
} from "../src/lib/stream-agent-response.ts";
|
||||
@@ -1482,6 +1486,216 @@ test("degraded delivery does not start a compose pass (BUG-961)", async () => {
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
const declaredWindowFixture = {
|
||||
name: "测试",
|
||||
toolInput: {
|
||||
year: 1997,
|
||||
month: 8,
|
||||
day: 8,
|
||||
city: "邯郸",
|
||||
lat: 36.4,
|
||||
lon: 114.2,
|
||||
tz: 8,
|
||||
ayanamsa: "raman" as const,
|
||||
rangeStart: "14:00",
|
||||
rangeEnd: "18:00",
|
||||
},
|
||||
truth: {
|
||||
birthDate: "1997-08-08",
|
||||
birthTimeSource: "period_only" as const,
|
||||
birthTimePeriod: "afternoon" as const,
|
||||
birthTimeStatus: "reported" as const,
|
||||
wrapsMidnight: false,
|
||||
placeLabel: "邯郸",
|
||||
placeCodes: { countryCode: "CN", provinceCode: null, cityCode: null, districtCode: null },
|
||||
placeId: null,
|
||||
placeType: null,
|
||||
placeProvider: null,
|
||||
timezoneId: null,
|
||||
timezoneSource: null,
|
||||
latitude: 36.4,
|
||||
longitude: 114.2,
|
||||
timezoneOffset: 8,
|
||||
},
|
||||
};
|
||||
|
||||
function windowPacket() {
|
||||
return {
|
||||
declared_range: { start: "14:00", end: "18:00", wraps_midnight: false },
|
||||
probe_count: 3,
|
||||
probes: [
|
||||
{ clock: "14:00", role: "range_start" as const },
|
||||
{ clock: "16:00", role: "interior" as const },
|
||||
{ clock: "18:00", role: "range_end" as const },
|
||||
],
|
||||
stable_layers: { planet_signs: { sun: "Cancer" } },
|
||||
varying_layers: { ascendant_signs: ["Libra", "Scorpio"] },
|
||||
blocked_layers: ["precise-timing"],
|
||||
answer_policy: {
|
||||
can_answer_direction: true,
|
||||
can_answer_precise_timing: false as const,
|
||||
birth_time_confidence: "declared_window" as const,
|
||||
candidate_is_confirmed: false as const,
|
||||
},
|
||||
result_hash: "fictional-window",
|
||||
};
|
||||
}
|
||||
|
||||
function makeWindowCtx(options: { fetchWindowChart?: () => Promise<ReturnType<typeof windowPacket>> } = {}) {
|
||||
const state = createConsultationRuntimeState();
|
||||
const ctx = createWindowConsultationAgentContext({
|
||||
userId: "u",
|
||||
sessionId: "s",
|
||||
requestId: "r-window-precompute",
|
||||
consultationMode: "declared_birth_window",
|
||||
declaredWindow: declaredWindowFixture,
|
||||
state,
|
||||
fetchWindowChart: options.fetchWindowChart ?? (async () => windowPacket()),
|
||||
runRangeReading: async () => {
|
||||
throw new Error("range-reading-unused-in-this-test");
|
||||
},
|
||||
});
|
||||
return { ctx, state, tools: createWindowConsultationTools(ctx) };
|
||||
}
|
||||
|
||||
async function writerToSend(send: (event: { type: "activity"; phase: "chart-calculation" | "evidence-validation" | "loading-method" | "answer-composition"; label: string }) => void) {
|
||||
return {
|
||||
custom: async (chunk: unknown) => {
|
||||
if (!chunk || typeof chunk !== "object") return;
|
||||
const value = chunk as { type?: unknown; data?: unknown };
|
||||
if (value.type !== "data-jyotish-activity") return;
|
||||
const event = consultationPublicActivityEvent(value.data);
|
||||
if (event) send(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("window precompute greens the contract without a model tool call (BUG-957)", async () => {
|
||||
const { ctx, state } = makeWindowCtx();
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, requireTool: true,
|
||||
pass4Mode: "declared_birth_window",
|
||||
warmup: async (send) => {
|
||||
await precomputeWindowConsultation(ctx, {
|
||||
question: "未来半年事业如何",
|
||||
writer: await writerToSend(send),
|
||||
});
|
||||
},
|
||||
stream: chunks(),
|
||||
toolStatus: () => "degraded",
|
||||
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /方向上可以推进/);
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false);
|
||||
assert.equal(state.consultationToolSuccessCount, 1);
|
||||
assert.equal(state.consultationToolCompleted, true);
|
||||
assert.ok(state.steps.some((step) =>
|
||||
step.kind === "tool" && step.name === "run-jyotish-window-consultation" && step.status === "completed"));
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
assert.ok(events.some((event) => {
|
||||
const item = event as { type?: string; phase?: string };
|
||||
return item.type === "activity" && item.phase === "chart-calculation";
|
||||
}));
|
||||
});
|
||||
|
||||
test("window precompute and a later tool call share one cache (BUG-957)", async () => {
|
||||
let fetches = 0;
|
||||
const { ctx, state, tools } = makeWindowCtx({
|
||||
fetchWindowChart: async () => {
|
||||
fetches += 1;
|
||||
return windowPacket();
|
||||
},
|
||||
});
|
||||
await precomputeWindowConsultation(ctx, { question: "事业如何" });
|
||||
const first = await tools["run-jyotish-window-consultation"].execute!(
|
||||
{ question: "事业如何" },
|
||||
toolContext,
|
||||
);
|
||||
const reconstructed = createWindowConsultationTools(ctx);
|
||||
const second = await reconstructed["run-jyotish-window-consultation"].execute!(
|
||||
{ question: "再问一次" },
|
||||
toolContext,
|
||||
);
|
||||
assert.equal(fetches, 1);
|
||||
assert.equal(state.consultationToolCallCount, 1);
|
||||
assert.equal(state.consultationToolSuccessCount, 1);
|
||||
assert.deepEqual(first, second);
|
||||
});
|
||||
|
||||
test("window precompute failure still degrades when the model writes without a tool (BUG-957)", async () => {
|
||||
const { ctx, state } = makeWindowCtx({
|
||||
fetchWindowChart: async () => {
|
||||
throw new Error("window_unavailable");
|
||||
},
|
||||
});
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, requireTool: true,
|
||||
pass4Mode: "declared_birth_window",
|
||||
warmup: async () => {
|
||||
await precomputeWindowConsultation(ctx, { question: "事业如何" }).catch(() => {});
|
||||
},
|
||||
stream: chunks(),
|
||||
toolStatus: () => "blocked",
|
||||
receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /方向上可以推进/);
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true);
|
||||
assert.equal(state.consultationToolSuccessCount, 0);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
test("warmup runs after skill events and before the model stream (BUG-957)", async () => {
|
||||
const state = toolOnlyRunState();
|
||||
const order: string[] = [];
|
||||
async function* chunks() {
|
||||
order.push("stream");
|
||||
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, requireTool: true,
|
||||
warmup: async (send) => {
|
||||
order.push("warmup");
|
||||
send({ type: "activity", phase: "chart-calculation", label: "正在比较声明出生窗口内的稳定层" });
|
||||
},
|
||||
stream: async () => {
|
||||
order.push("open");
|
||||
return chunks();
|
||||
},
|
||||
toolStatus: () => "ready",
|
||||
receipt: () => receipt(state),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.deepEqual(order, ["warmup", "open", "stream"]);
|
||||
const types = events.map((event) => (event as { type?: string }).type);
|
||||
const skillIdx = types.lastIndexOf("skill.completed");
|
||||
const activityIdx = types.findIndex((type, index) =>
|
||||
type === "activity" && index > skillIdx && (events[index] as { phase?: string }).phase === "chart-calculation");
|
||||
const answerIdx = types.indexOf("answer.delta");
|
||||
assert.ok(skillIdx >= 0 && activityIdx > skillIdx && answerIdx > activityIdx);
|
||||
});
|
||||
|
||||
test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => {
|
||||
const bindingState = createConsultationRuntimeState();
|
||||
let bindingError = "";
|
||||
|
||||
@@ -347,3 +347,28 @@ test("agentic consultation is the safe default and legacy is explicit rollback",
|
||||
assert.match(route, /mode === "legacy"/);
|
||||
assert.match(route, /mode !== "canary"/);
|
||||
});
|
||||
|
||||
test("window calculation is precomputed on the agent context cache (BUG-957)", () => {
|
||||
const windowFactory = tools.slice(tools.indexOf("export function createWindowConsultationTools"));
|
||||
const natalFactory = tools.slice(
|
||||
tools.indexOf("export function createConsultationTools"),
|
||||
tools.indexOf("export async function precomputeWindowConsultation"),
|
||||
);
|
||||
assert.match(tools, /export async function precomputeWindowConsultation/);
|
||||
assert.match(tools, /calculationCache/);
|
||||
assert.doesNotMatch(windowFactory, /let calculation/);
|
||||
assert.match(natalFactory, /let calculation/);
|
||||
assert.match(mastra, /tools: createWindowConsultationTools\(context\)/);
|
||||
assert.match(mastra, /If this turn already includes a server-owned window packet/);
|
||||
const windowBranch = route.slice(
|
||||
route.indexOf("if (shouldRunDeclaredWindowWorkflow"),
|
||||
route.indexOf("if (!prepared.serverChart)"),
|
||||
);
|
||||
const natalBranch = route.slice(route.indexOf("if (!prepared.serverChart)"));
|
||||
assert.match(windowBranch, /precomputeWindowConsultation/);
|
||||
assert.match(windowBranch, /warmup:/);
|
||||
assert.match(windowBranch, /windowPrecomputedPacketMessage/);
|
||||
assert.doesNotMatch(windowBranch, /toolChoice:\s*"required"/);
|
||||
assert.doesNotMatch(natalBranch, /precomputeWindowConsultation/);
|
||||
assert.doesNotMatch(natalBranch, /warmup:/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user