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>
816 lines
37 KiB
TypeScript
816 lines
37 KiB
TypeScript
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;
|
||
},
|
||
};
|
||
}
|