Files
Jyotisha/frontend/src/mastra/consultation-tools.ts
T
jesse-ux 53d78137ff fix(consult): 申报时段计算改为服务端预跑并走同请求缓存(BUG-957)
窗口计算挂在 agent context 缓存上,模型开口前预跑并注入 packet;工具再调用命中同请求缓存,成功次数仍为 1。
2026-09-18 18:45:19 +08:00

963 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isDeepStrictEqual } from "node:util";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import {
consultationDomainPlanValueSchema,
validateConsultationDomainPlan,
type ConsultationDomain,
} from "../lib/consultation-domain-registry.ts";
import { applyBirthTimeModeToWorkflowContext, type ConsultationBirthTimeMode } from "../lib/consultation-birth-time-mode.ts";
import { consultationMethodologyForDomains } from "../lib/consultation-methodology.ts";
import type { DeclaredBirthWindowConsultation, ServerChartConsultation } from "../lib/consultation-route-service.ts";
import { fetchDeclaredWindowChart } from "../lib/declared-window-chart.ts";
import { runV9RangeReading } from "../lib/rectification-agentic/v9/engine-client.ts";
import { createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts";
import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts";
import type { AgentModelFinishReason } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts";
import { pinsConsultationDomains, type ConsultationEntrypoint } from "../lib/consultation-entrypoint.ts";
import {
dailyConsultationThinkingPlan,
natalConsultationThinkingPlan,
windowConsultationThinkingPlan,
type PublicThinkingSection,
} from "../lib/consultation-thinking-plan.ts";
import {
consultationEvidencePacketSchema,
consultationInputSchema,
consultationWorkflowFailureCode,
consultationWorkflowReceipt,
runConsultationWorkflow,
toAgentConsultationContext,
toModelOutput,
type ConsultationEvidencePacket,
} from "./consultation-workflow.ts";
export { AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
// The budgets that bound one consultation run. They all constrain the same
// wall clock, so they are declared together and must be changed together.
//
// One chart calculation takes about 20s and the route's maxDuration caps the
// request near 120s, so time, not steps, is the binding constraint: three
// failed calculations exhaust the timeout no matter how many steps remain. The
// step budget therefore only has to cover the longest useful shape—skill load,
// a couple of progressive-disclosure reference reads, one calculation plus one
// retry, and the answer turn—since a larger budget cannot buy more time.
//
// The domain loop below is sequential and the Python API is a single GIL-bound
// process, so latency scales linearly with domain count and cannot be traded
// for concurrency. The domain cap is therefore derived from the clock instead
// of chosen: how many domains fit once the answer reserve is set aside. The
// reserve is what a staging three-domain plan actually left over—62.9s of
// calculation inside the 110s budget—so it is measured, not guessed.
export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const AGENT_SLICE_MAX_STEPS = 1;
export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Step 0 exposes only the natal chart tool. Some thinking-mode providers
* reject named and required toolChoice (BUG-282, BUG-937); the first step
* therefore narrows with activeTools and leaves the choice as auto. Later
* steps stay auto so the model can write after the request-scoped
* calculation is in hand.
*
* Window and general agents must not share this hook: they do not own this tool.
*/
export function consultationNatalPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
const CONSULTATION_DOMAIN_DURATION_MS = 31_000;
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
export const MAX_CONSULTATION_DOMAINS = Math.max(
1,
Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS),
);
export function consultationGenerationSettings(model?: unknown) {
return agentGenerationSettings(model, { thinking: "enabled" });
}
export function consultationContinueGenerationSettings(model?: unknown) {
return agentGenerationSettings(model, { thinking: "disabled" });
}
export function consultationSliceGenerationSettings(model?: unknown) {
return agentGenerationSettings(model, {
thinking: "enabled",
answerTokens: AGENT_SLICE_ANSWER_OUTPUT_TOKENS,
thinkingTokens: AGENT_SLICE_THINKING_OUTPUT_TOKENS,
reasoningEffort: "low",
});
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
// canonicalizes instead of failing outright. The executable cap is enforced
// after canonicalization, where it can degrade and disclose rather than throw.
const MAX_CONSULTATION_DOMAIN_PLAN_VALUES = 6;
// The legal values have to be stated in the schema the model is handed, not only
// enforced in the registry behind execute(). As a free-form string this accepted
// any identifier the skill's methodology happened to name—the strict-workflow
// checklist labels are not domains—so an invented value passed validation and
// died inside execute, spending a step and a tool.failed to learn a vocabulary
// the schema could have listed. Aliases stay accepted, so this enumerates them
// alongside the canonical ids rather than narrowing what a call may say.
const domainPlanValueSchema = consultationDomainPlanValueSchema;
// The model may only express a domain plan one way. A second, mutually
// exclusive field was representable here but rejected at execution, so every
// call that set both spent a model step to learn a rule the schema never
// stated. Internal callers keep the single-value form; see canonicalDomainPlan.
const consultationToolInputSchema = z.object({
question: z.string().trim().min(1).max(500),
domains: z.array(domainPlanValueSchema).min(1).max(MAX_CONSULTATION_DOMAIN_PLAN_VALUES).optional(),
}).strict();
const MAX_RECORDED_STEPS = 32;
export type ConsultationStepBudget = {
planned: number;
reservedValidation: number;
total: number;
};
export type ConsultationRuntimeStep = {
sequence: number;
kind: "skill" | "tool" | "validation" | "abort";
name: string;
status: "completed" | "failed";
durationMs?: number;
failureCode?: string;
};
export type ConsultationRuntimeState = {
/**
* The server binds the skill method into the agent's instructions before the
* model runs, so this is true for the whole run. It was a model action once,
* which meant a run could reach the answer with no method loaded and had to be
* retried into the contract; the field is kept so that anything downstream
* still has to state which method a receipt describes.
*/
jyotishSkillBound: boolean;
skillReferenceReadCount: number;
methodologySectionCount: number;
consultationToolStarted: boolean;
consultationToolCompleted: boolean;
consultationToolCallCount: number;
consultationToolSuccessCount: number;
consultationToolDurationMs?: number;
workflowReceipt?: WorkflowReceipt;
techniqueTruth?: string;
techniqueAuditTable?: TechniqueAuditRow[];
thinkingPlan?: PublicThinkingSection[];
planOverrideIgnored?: boolean;
steps: ConsultationRuntimeStep[];
stepBudget: ConsultationStepBudget;
stepsTruncated: boolean;
// Diagnostics for the model step budget. Internal only: the public receipt is
// strict and would reject them, so they never enter it.
modelStepCount: number;
modelFinishReason?: AgentModelFinishReason;
};
export function createConsultationRuntimeState(options: { plannedSteps?: number; reservedValidationSteps?: number } = {}): ConsultationRuntimeState {
// Contract retry, empty-answer retry, and length-continue each take a validation slot.
const reservedValidation = Math.max(0, Math.min(MAX_RECORDED_STEPS - 1, Math.floor(options.reservedValidationSteps ?? 3)));
const planned = Math.max(1, Math.min(MAX_RECORDED_STEPS - reservedValidation, Math.floor(options.plannedSteps ?? 6)));
const state: ConsultationRuntimeState = {
jyotishSkillBound: true,
skillReferenceReadCount: 0,
methodologySectionCount: 0,
consultationToolStarted: false,
consultationToolCompleted: false,
consultationToolCallCount: 0,
consultationToolSuccessCount: 0,
steps: [],
stepBudget: { planned, reservedValidation, total: planned + reservedValidation },
stepsTruncated: false,
modelStepCount: 0,
};
// Binding is the run's first step and it costs no model step, so it is
// recorded here rather than observed from the stream. It carries no duration
// because nothing is fetched: the method is already in the instructions.
appendConsultationRuntimeStep(state, {
kind: "skill",
name: "jyotish-vedic-astrology",
status: "completed",
});
return state;
}
/**
* The controlled fields that report how the model loop ended. Kept beside the
* public allowlist so both directions of the boundary are visible: these go to
* the observability log only, never to the client receipt.
*/
export function consultationModelStepTelemetry(state: ConsultationRuntimeState) {
return {
modelStepCount: state.modelStepCount,
skillReferenceReads: state.skillReferenceReadCount,
methodologySections: state.methodologySectionCount,
...(state.modelFinishReason === undefined ? {} : { modelFinishReason: state.modelFinishReason }),
};
}
/**
* Public receipts carry only the fields the client contract allows. Building
* the list from an explicit allowlist keeps internal diagnostics, such as the
* workflow failure classification, from reaching the response.
*/
export function publicConsultationRuntimeSteps(state: ConsultationRuntimeState) {
return state.steps.map((step) => ({
sequence: step.sequence,
kind: step.kind,
name: step.name,
status: step.status,
...(step.durationMs === undefined ? {} : { durationMs: step.durationMs }),
}));
}
/**
* Why a consultation tool call failed, as a closed machine code. The workflow
* classifier only recognises its own transport faults and returns undefined for
* everything else, so the failures raised by this module — a rejected domain
* plan above all — reached the observability log with no code at all. The one
* record that exists to explain a failure must never be the one without a
* reason, so every error now resolves to a code.
*/
export function consultationToolFailureCode(error: unknown): string {
const workflowCode = consultationWorkflowFailureCode(error);
if (workflowCode) return workflowCode;
const message = error instanceof Error ? error.message : "";
if (message === "invalid_consultation_domain_plan" || message === "unsupported_consultation_domain") {
return "invalid_domain_plan";
}
return "unexpected_error";
}
export function appendConsultationRuntimeStep(state: ConsultationRuntimeState, step: Omit<ConsultationRuntimeStep, "sequence">) {
if (state.steps.length >= state.stepBudget.total) {
state.stepsTruncated = true;
return false;
}
state.steps.push({ sequence: state.steps.length + 1, ...step });
return true;
}
export function consultationStepBudgetReceipt(state: ConsultationRuntimeState) {
return {
planned: state.stepBudget.total,
used: state.steps.length,
remaining: Math.max(0, state.stepBudget.total - state.steps.length),
truncated: state.stepsTruncated,
};
}
export type ConsultationAgentContext = Readonly<{
userId: string;
sessionId: string;
requestId: string;
consultationMode: Extract<ConsultationBirthTimeMode, "verified_chart" | "unverified_birth_time">;
// New consultation routes always provide the server-owned plan/theme. Keep
// these optional while older PR7 callers migrate so the multi-domain tool
// contract remains backwards compatible.
plan?: ConsultationPlan;
theme?: ConsultationDomain;
entrypoint?: ConsultationEntrypoint;
serverChart: ServerChartConsultation;
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
runWorkflow?: typeof runConsultationWorkflow;
// The domain loop budgets itself against this clock, so tests can drive the
// deadline without waiting for it.
now?: () => number;
}>;
export type WindowConsultationCalculationCache = {
current: Promise<Record<string, unknown>> | null;
};
export type WindowConsultationAgentContext = Readonly<{
userId: string;
sessionId: string;
requestId: string;
consultationMode: "declared_birth_window";
plan?: ConsultationPlan;
theme?: ConsultationDomain;
declaredWindow: DeclaredBirthWindowConsultation;
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
now?: () => number;
fetchWindowChart?: typeof fetchDeclaredWindowChart;
runRangeReading?: typeof runV9RangeReading;
calculationCache?: WindowConsultationCalculationCache;
}>;
export function createConsultationAgentContext(context: ConsultationAgentContext) {
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)}`;
}
/**
* Resolves the one domain plan a call may express. The model-facing schema
* declares only `domains` and rejects anything else before execute() runs, so
* the single-value `theme` form and its mutual exclusion remain the contract
* for callers that build a plan without that schema.
*/
function modelRequestedDifferentDomains(
input: { domains?: readonly unknown[] },
pinned: ConsultationDomain,
): boolean {
if (input.domains === undefined) return false;
try {
const requested = validateConsultationDomainPlan(input.domains, MAX_CONSULTATION_DOMAIN_PLAN_VALUES);
return requested.length !== 1 || requested[0] !== pinned;
} catch {
return true;
}
}
export function canonicalDomainPlan(
input: { domains?: readonly unknown[]; theme?: unknown },
context: Pick<ConsultationAgentContext, "plan" | "theme" | "entrypoint">,
): ConsultationDomain[] {
if (pinsConsultationDomains(context.entrypoint) && context.theme) {
return [context.theme];
}
if (input.domains !== undefined && input.theme !== undefined) {
throw new Error("invalid_consultation_domain_plan");
}
// A legacy single-theme Agent call must not override the route-selected,
// server-owned consultation theme. Multi-domain calls remain Agent-selected
// from the strict registry, preserving the newer PR7 contract.
if (input.domains === undefined && context.plan && context.theme) {
return [context.theme];
}
const values = input.domains ?? (input.theme === undefined ? [] : [input.theme]);
return validateConsultationDomainPlan(values, MAX_CONSULTATION_DOMAIN_PLAN_VALUES);
}
/**
* The domains a plan may actually execute, and the ones the clock cannot pay
* for. Truncating and disclosing beats rejecting the call: a plan larger than
* the cap still answers the domains it covered, and the caller can see which
* ones it did not.
*/
export function executableDomainPlan(requested: readonly ConsultationDomain[]) {
return {
domains: requested.slice(0, MAX_CONSULTATION_DOMAINS),
omittedDomains: requested.slice(MAX_CONSULTATION_DOMAINS),
};
}
/**
* Whether the next domain is projected to finish inside the loop's share of the
* run budget, judged by how long the domains already executed actually took.
* The first domain always runs; without it there is nothing to answer from.
*/
export function domainFitsRunBudget(elapsedMs: number, executedCount: number) {
if (executedCount < 1) return true;
return elapsedMs + elapsedMs / executedCount <= CONSULTATION_DOMAIN_WALL_CLOCK_MS;
}
type DomainExecution = Readonly<{
domain: ConsultationDomain;
context: ReturnType<typeof toAgentConsultationContext>;
modelOutput: ReturnType<typeof toModelOutput>;
receipt: ReturnType<typeof consultationWorkflowReceipt>;
}>;
type ConsultationStatus = "ready" | "degraded" | "blocked";
const consultationStatusRank: Readonly<Record<ConsultationStatus, number>> = { ready: 0, degraded: 1, blocked: 2 };
/**
* Merging domain policies may only ever restrict. Statuses take the worst,
* never the best, so one blocked domain blocks the merged answer.
*/
function worstConsultationStatus(values: readonly ConsultationStatus[]): ConsultationStatus {
return values.reduce<ConsultationStatus>(
(worst, value) => (consultationStatusRank[value] > consultationStatusRank[worst] ? value : worst),
"ready",
);
}
function unionStringList(lists: readonly unknown[]) {
const merged: string[] = [];
for (const list of lists) {
for (const item of Array.isArray(list) ? list : []) {
if (typeof item === "string" && item && !merged.includes(item)) merged.push(item);
}
}
return merged.slice(0, 24);
}
function aggregateWorkflowReceipt(
executions: readonly DomainExecution[],
omittedDomains: readonly ConsultationDomain[],
): WorkflowReceipt {
const domains = executions.map((execution) => execution.domain);
const missingLayers = unionStringList(executions.map((execution) => (
execution.receipt.missingLayers === "none"
? []
: execution.receipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean)
)));
const statuses = executions.map((execution) => execution.receipt.status);
const minuteSensitiveThemes = unionStringList(executions.map((execution) => (
execution.receipt.minuteSensitiveThemes ?? []
)));
return {
route: executions.length === 1 && omittedDomains.length === 0 ? executions[0].receipt.route : "multi-domain",
// A plan the clock could not finish is by definition not the full answer,
// so truncation degrades the run even when every executed domain was ready.
status: worstConsultationStatus([...statuses, ...(omittedDomains.length > 0 ? ["degraded" as const] : [])]),
preciseTiming: executions.every((execution) => execution.receipt.preciseTiming === "allowed") ? "allowed" : "blocked",
missingLayers,
domains,
...(omittedDomains.length > 0 ? { omittedDomains: [...omittedDomains] } : {}),
...(minuteSensitiveThemes.length > 0 ? { minuteSensitiveThemes } : {}),
};
}
function aggregateTechniqueTruth(executions: readonly DomainExecution[]) {
const values = [...new Set(executions.map((execution) => execution.receipt.techniqueTruth))];
return values.length === 1 ? values[0] : "mixed";
}
type DomainConsultation = ConsultationEvidencePacket & { domain: ConsultationDomain };
type ClaimCard = ConsultationEvidencePacket["claim_cards"][number];
type EvidenceRecord = Record<string, unknown>;
function evidenceRecord(value: unknown): EvidenceRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as EvidenceRecord : {};
}
/**
* Merges the domain answer policies into one the model may obey directly.
*
* Every rule here is chosen so the merge cannot authorize a claim that any
* single domain forbade: a permission needs unanimous consent, a limitation or
* prohibition needs only one domain to raise it. Fields beyond the three the
* projection emits today are merged by the same rule rather than dropped, so a
* prohibition list added later unions instead of silently widening the contract.
*/
export function mergeConsultationAnswerPolicies(policies: readonly EvidenceRecord[]) {
const merged: EvidenceRecord = {
can_answer_direction: policies.every((policy) => policy.can_answer_direction === true),
can_answer_precise_timing: policies.every((policy) => policy.can_answer_precise_timing === true),
};
if (policies.some((policy) => typeof policy.should_lead_with_limitations === "boolean")) {
merged.should_lead_with_limitations = policies.some((policy) => policy.should_lead_with_limitations === true);
}
const conflicts: string[] = [];
for (const key of [...new Set(policies.flatMap((policy) => Object.keys(policy)))]) {
if (key in merged) continue;
const values = policies.map((policy) => policy[key]);
if (values.every((value) => typeof value === "boolean" || value === undefined)) {
// `can_*` names a permission and needs every domain; anything else names a
// caution and is raised by one.
merged[key] = key.startsWith("can_")
? values.every((value) => value === true)
: values.some((value) => value === true);
} else if (values.some((value) => Array.isArray(value))) {
merged[key] = unionStringList(values);
} else if (values.every((value) => isDeepStrictEqual(value, values[0]))) {
merged[key] = values[0];
} else {
conflicts.push(key);
}
}
if (conflicts.length > 0) {
// An unmergeable policy field is a disagreement, not permission. Say so and
// make the answer lead with its limits rather than pick a side.
merged.should_lead_with_limitations = true;
merged.unresolved_policy_fields = conflicts.slice(0, 24);
}
return merged;
}
/**
* One top-level answer contract for a whole domain plan, in exactly the shape a
* single-domain result has. The instructions state their output rules against
* these paths, so a shape that omitted them left the model with no contract
* authorizing it to speak at all.
*/
function mergeConsultationEvidencePackets(
packets: readonly ConsultationEvidencePacket[],
options: Readonly<{ route: string; claimCards: readonly ClaimCard[]; truncated: boolean }>,
): ConsultationEvidencePacket {
const contracts = packets.map((packet) => packet.evidence_contract);
const limitations = [...new Set(contracts
.map((contract) => contract.user_facing_limitation)
.filter((value): value is string => typeof value === "string" && value.trim().length > 0))];
const boundaries = [...new Set(packets.map((packet) => packet.rectification.boundary))];
const policy = mergeConsultationAnswerPolicies(contracts.map((contract) => evidenceRecord(contract.answer_policy)));
const audit = contracts.find((contract) => Array.isArray(contract.technique_audit_table))?.technique_audit_table;
const varga = contracts.find((contract) => contract.varga_spectrum !== undefined)?.varga_spectrum;
const western = contracts.find((contract) => contract.western_spectrum !== undefined)?.western_spectrum;
return consultationEvidencePacketSchema.parse({
packet_version: "consultation-evidence-packet-v2",
question: packets.find((packet) => typeof packet.question === "string")?.question,
route: options.route,
// A plan the clock could not finish is not a complete answer, whatever the
// executed domains reported on their own.
status: worstConsultationStatus([
...packets.map((packet) => packet.status),
...(options.truncated ? ["degraded" as const] : []),
]),
evidence_contract: {
// An available layer stays available: it was genuinely computed for at
// least one domain, and claiming otherwise would deny real evidence. What
// restricts the answer is the union of what is missing or blocked.
available_layers: unionStringList(contracts.map((contract) => contract.available_layers)),
missing_route_layers: unionStringList(contracts.map((contract) => contract.missing_route_layers)),
hard_blockers: unionStringList(contracts.map((contract) => contract.hard_blockers)),
answer_policy: options.truncated
? { ...policy, should_lead_with_limitations: true }
: policy,
...(limitations.length > 0
? { user_facing_limitation: limitations.join(" ").slice(0, 800) }
: {}),
...(audit !== undefined ? { technique_audit_table: audit } : {}),
...(varga !== undefined ? { varga_spectrum: varga } : {}),
...(western !== undefined ? { western_spectrum: western } : {}),
must_use_layers: unionStringList(contracts.map((contract) => contract.must_use_layers)),
},
claim_cards: options.claimCards,
presentation: packets[0]?.presentation ?? {
template: "skill_level_2",
required_blocks: [
"raw_structure",
"raman_six_step",
"yoga_table",
"timing",
"synthesis",
"technique_audit_table",
"modern_wrap",
],
},
// `not_auto_rectified` is the restrictive boundary, so one domain reporting
// it keeps the merged plan inside it.
rectification: {
boundary: boundaries.includes("not_auto_rectified")
? "not_auto_rectified"
: boundaries.length === 1 ? boundaries[0] : boundaries.join(","),
},
});
}
/**
* The natal projection is the same chart for every domain, so a three-domain
* payload repeated an identical, large block three times. Lift it to a single
* copy when the domains truly agree, and leave it per-domain when they do not
* rather than pick one and call it shared.
*/
function hoistSharedNatalFoundation(consultations: readonly DomainConsultation[]) {
const natalCards = consultations.map((consultation) => (
consultation.claim_cards.find((card) => card.category === "natal_foundation")
));
const shared = natalCards[0];
if (!shared || natalCards.some((card) => !isDeepStrictEqual(card, shared))) {
return { sharedClaimCards: [] as ClaimCard[], consultations };
}
return {
sharedClaimCards: [shared],
consultations: consultations.map((consultation) => ({
...consultation,
claim_cards: consultation.claim_cards.filter((card) => card.category !== "natal_foundation"),
})),
};
}
function toModelDomainPlanContext(
executions: readonly DomainExecution[],
omittedDomains: readonly ConsultationDomain[],
) {
const domains = executions.map((execution) => execution.domain);
const consultations: DomainConsultation[] = executions.map((execution) => ({
domain: execution.domain,
...execution.modelOutput,
}));
const success = executions.every((execution) => execution.context.success);
const methodology = consultationMethodologyForDomains(domains);
const plan = {
success,
domains,
omitted_domains: [...omittedDomains],
// The skill states which checklist each route must be read against, and the
// route is already known here, so the method travels with the evidence it
// applies to instead of depending on the model opening the right file out of
// the package listing.
...(methodology ? { methodology } : {}),
};
if (consultations.length === 1 && omittedDomains.length === 0) {
return { ...consultations[0], ...plan, consultations };
}
const hoisted = consultations.length === 1
? { sharedClaimCards: consultations[0].claim_cards, consultations }
: hoistSharedNatalFoundation(consultations);
const merged = mergeConsultationEvidencePackets(executions.map((execution) => execution.modelOutput), {
route: consultations.length === 1 ? consultations[0].route : "multi-domain",
claimCards: hoisted.sharedClaimCards,
truncated: omittedDomains.length > 0,
});
return { ...merged, ...plan, consultations: hoisted.consultations };
}
export function createConsultationTools(ctx: ConsultationAgentContext) {
let calculation: Promise<ReturnType<typeof toModelDomainPlanContext>> | null = null;
const consultationTool = createTool({
id: "run-jyotish-consultation",
description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`,
inputSchema: consultationToolInputSchema,
execute: async (input, context) => {
const requestedDomains = canonicalDomainPlan(input, ctx);
if (
pinsConsultationDomains(ctx.entrypoint)
&& ctx.theme
&& modelRequestedDifferentDomains(input, ctx.theme)
) {
ctx.state.planOverrideIgnored = true;
}
const { domains } = executableDomainPlan(requestedDomains);
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 {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan),
receipt: consultationWorkflowReceipt(guarded),
});
}
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
const omittedDomains = requestedDomains.slice(executions.length);
const workflowReceipt = aggregateWorkflowReceipt(executions, omittedDomains);
ctx.state.workflowReceipt = ctx.state.planOverrideIgnored
? { ...workflowReceipt, planOverrideIgnored: true }
: workflowReceipt;
ctx.state.techniqueTruth = aggregateTechniqueTruth(executions);
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-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs });
const modelContext = toModelDomainPlanContext(executions, omittedDomains);
ctx.state.methodologySectionCount = modelContext.methodology?.sections.length ?? 0;
ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows(
modelContext.evidence_contract?.technique_audit_table,
);
ctx.state.thinkingPlan = ctx.entrypoint === "daily_starlanguage"
? dailyConsultationThinkingPlan()
: natalConsultationThinkingPlan({
domains: executions.map((execution) => execution.domain),
requiredBlocks: modelContext.presentation?.required_blocks,
mustUseLayers: Array.isArray(modelContext.evidence_contract?.must_use_layers)
? modelContext.evidence_contract.must_use_layers.filter((item): item is string => typeof item === "string")
: undefined,
});
return modelContext;
} catch (error) {
ctx.state.consultationToolDurationMs = now() - startedAt;
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: "run-jyotish-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;
}
},
});
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) {
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) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext),
});
return { "run-jyotish-window-consultation": windowTool };
}
/**
* Activation is no longer a model action, so nothing here watches for it: the
* method is bound before the run starts and recorded with the state. What is
* left to observe is the opposite direction — whether the model went past the
* method it was given and opened a reference of its own.
*/
export function createConsultationRuntimeHooks(state: ConsultationRuntimeState) {
return {
afterToolCall({ toolName, error }: { toolName: string; error?: unknown }) {
if ((toolName === "skill_read" || toolName === "read_file") && !error) state.skillReferenceReadCount += 1;
},
};
}