Files
Jyotisha/frontend/src/mastra/consultation-tools.ts
T
Jesse_ChenandCursor 56daf6be49 fix(consult): pass birth-time accuracy instead of treating every chart as unrectified
Hospital records and adopted rectification times were still fed to the model as not_auto_rectified because the chart request omitted declared_accuracy/time_source and mastra hardcoded the boundary. Map profile truth into the engine request, keep rectified for accepted/confirmed active times only, and leave window/general guards unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 01:21:40 +08:00

816 lines
37 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 { 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 {
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;
const CONSULTATION_DOMAIN_DURATION_MS = 21_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_DOMAINS).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";
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[];
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;
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 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;
}>;
export function createConsultationAgentContext(context: ConsultationAgentContext) {
return Object.freeze(context);
}
export function createWindowConsultationAgentContext(context: WindowConsultationAgentContext) {
return Object.freeze(context);
}
/**
* 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.
*/
export function canonicalDomainPlan(
input: { domains?: readonly unknown[]; theme?: unknown },
context: Pick<ConsultationAgentContext, "plan" | "theme">,
): ConsultationDomain[] {
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);
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] } : {}),
};
}
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 at most ${MAX_CONSULTATION_DOMAINS} allowlisted 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, or omit it entirely to accept the domain the server already selected. 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; if the clock runs short the server executes the domains that fit 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);
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,
});
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);
ctx.state.workflowReceipt = aggregateWorkflowReceipt(executions, omittedDomains);
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 = 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 };
}
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,
});
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"] : []),
],
};
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,
status: ctx.state.workflowReceipt.status,
evidence_contract: {
answer_policy: packet.answer_policy,
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;
}
},
});
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;
},
};
}