Files
Jyotisha/frontend/src/mastra/consultation-tools.ts
T
jesse-uxandJesse_Chen b8bdc003c3
Independent Staging Quality Gate / validate (push) Successful in 19m41s
Independent Staging Quality Gate / publish (push) Successful in 3m52s
fix(consult): open rectification when the user asks directly (BUG-1306)
2026-10-12 14:17:05 +08:00

1565 lines
71 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 {
RECTIFICATION_DIRECT_NEW_COPY,
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_TOOL_ID,
type RectificationOfferDecision,
type RectificationOfferRecord,
} from "../lib/rectification-in-chat.ts";
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, EvidenceCardTelemetry } 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, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import {
buildEvidenceCard,
citedEvidenceCardFields,
EVIDENCE_LOOKUP_SECTIONS,
evidenceCardTokenEstimate,
EVIDENCE_LOOKUP_KP_NOTE,
type EvidenceCard,
type EvidenceLookupSection,
} from "../lib/consultation-evidence-card.ts";
import { pinsConsultationDomains, type ConsultationEntrypoint } from "../lib/consultation-entrypoint.ts";
import type { ProfileGender } from "../lib/profile-gender.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;
/**
* The answer phase runs on its own clock, started when the answer phase
* starts. It used to share AGENT_TIMEOUT_MS with the tool loop, so a long loop
* left the answer a few seconds and Mastra's abort cut it mid-sentence
* (BUG-1051; BUG-944 had already asked for one budget per stream).
*
* Since BUG-1053 there is no separate compose stream: the tool loop's own step
* after the calculation result writes the answer, with that result in its
* context. The phase therefore starts when the calculation result is in hand
* (the run contract turns ready), not when a second stream opens, and it
* covers the rest of the loop plus any length continuation or empty-answer
* retry.
*
* 300s is a hang guard, not a length limit (product decision 2026-10-01,
* TASK-consult-answer-clock-20261001). The old 70s was sized for a
* non-reasoning writer (about 90-100 tok/s, PROGRESS-report-writer-failure-20260902).
* The answer step keeps provider thinking on, so thinking tokens come out of
* this clock too, and a reasoning model spends most of its time there: in the
* 10-01 real-model run (docs/testing/consult-plain-answer-20261001-model-runs.md)
* a parents answer on deepseek-v4-pro took 77s end to end and would have been
* cut, while deepseek-flash took 30-41s with about 85% of its tokens in
* thinking. The answer shape no longer has a word cap either, so the clock
* only has to stop a provider that hangs. A run that does hit it still ends
* as answer_truncated and is not charged (BUG-1051).
*
* The calculation must finish inside the 110s tool phase, so the worst case is
* 110 + 300 = 410s; the route's maxDuration must stay above the sum. The
* domain budget below is derived from the tool phase and its own reserve, not
* from this clock, so raising it does not change how many domains run.
*/
export const CONSULTATION_ANSWER_TIMEOUT_MS = 300_000;
export type ConsultationRunClock = Readonly<{
/** The tool phase's deadline. Tools and precompute run under it, unchanged. */
toolSignal: AbortSignal;
/**
* The model loop's signal: the tool-phase deadline until the answer phase
* starts, then the answer clock. A loop that is writing the answer is never
* cut by the tool phase's timer.
*/
loopSignal: AbortSignal;
/**
* The answer clock. The first call starts it; every later call (loop hand
* over, continuation, retries) returns the same signal, so the phase as a
* whole is bounded.
*/
answerSignal: () => AbortSignal;
}>;
/**
* One consultation run's clocks (BUG-1053). `answerReady` covers the race
* where the calculation settles just before the tool-phase timer fires but
* the stream consumer has not seen the result yet: the loop is then handed to
* the answer clock instead of being cut.
*
* `answerFromStart` is for a loop with no calculation to wait for (the
* general / no-birth-minute agent has no tools): its first step already writes
* the answer, so the loop runs on the answer clock from the start instead of
* being cut by the tool phase's timer (TASK-consult-answer-clock-20261001).
*/
export function createConsultationRunClock(options: {
toolPhaseMs?: number;
answerMs?: number;
answerReady?: () => boolean;
answerFromStart?: boolean;
} = {}): ConsultationRunClock {
const toolPhaseMs = options.toolPhaseMs ?? AGENT_TIMEOUT_MS;
const answerMs = options.answerMs ?? CONSULTATION_ANSWER_TIMEOUT_MS;
const toolSignal = AbortSignal.timeout(toolPhaseMs);
const loop = new AbortController();
let answer: AbortSignal | null = null;
const answerSignal = () => {
if (!answer) {
const started = AbortSignal.timeout(answerMs);
answer = started;
started.addEventListener("abort", () => loop.abort(started.reason), { once: true });
}
return answer;
};
toolSignal.addEventListener("abort", () => {
if (answer) return;
if (options.answerReady?.()) {
answerSignal();
return;
}
loop.abort(toolSignal.reason);
}, { once: true });
if (options.answerFromStart) answerSignal();
return Object.freeze({ toolSignal, loopSignal: loop.signal, answerSignal });
}
export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
/**
* The one-shot, read-only lookup of a section outside the evidence card
* (TASK-consult-evidence-card-20260927 D8). It reads this request's finished
* calculation only; it never calculates.
*/
export const CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID = "read-consultation-evidence";
export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
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;
/** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */
rectificationOffer?: RectificationOfferRecord;
/**
* Set when this turn explicitly asked to rectify (BUG-1306). The stream
* replaces the answer with `sentence` and does not charge a reading.
* `new` opens a case; `resume` opens the one already in progress; `refused`
* only says why it cannot.
*/
rectificationDirectEntry?: {
kind: "new" | "resume" | "refused";
sentence: string;
sourceSessionId: string;
resumeSessionId?: string;
};
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;
/** Step 0 and step 1 clocks. Observability only; absent until the stream records them. */
modelStepTimings?: ReadonlyArray<{
index: 0 | 1;
durationMs: number | null;
reasoningTokens: number | null;
outputTokens: number | null;
inputTokens: number | null;
cachedInputTokens: number | null;
}>;
/** Milliseconds from step 1 start to the first answer-body character. Null when that character never arrived. */
answerReasoningMs?: number | null;
// How the stream that wrote the answer ended (compose, continuation or answer
// retry; the main loop when a path has no compose; the last stream when no
// answer was written). "missing" means the stream closed without a finish
// chunk. Observability only, never in the public receipt (BUG-1051).
composeFinishReason?: AgentModelFinishReason | "missing";
composeAborted?: boolean;
answerVisibleChars?: number;
/**
* The evidence card the answer model was given (T3). Server-side only: the
* telemetry (T6) reads its size and field ids, never logs its values.
*/
evidenceCard?: EvidenceCard;
/** Characters of the card's model-visible JSON (claim cards + card meta). */
evidenceCardChars?: number;
/** Characters of the whole tool result the model saw. */
modelVisibleChars?: number;
/** Calls to the one-shot evidence lookup this turn, including refused ones. */
evidenceLookupCallCount: number;
};
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,
evidenceLookupCallCount: 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 }),
...(state.composeFinishReason === undefined ? {} : { composeFinishReason: state.composeFinishReason }),
...(state.composeAborted === undefined ? {} : { composeAborted: state.composeAborted }),
...(state.answerVisibleChars === undefined ? {} : { answerVisibleChars: state.answerVisibleChars }),
...(state.modelStepTimings === undefined ? {} : { modelSteps: state.modelStepTimings }),
...(state.answerReasoningMs === undefined ? {} : { answer: { reasoning_ms: state.answerReasoningMs } }),
};
}
/**
* The six-field evidence-card record for the observability log (D7). The
* answer is read only to find which card fields it quoted (ISO date, degree,
* planet-in-sign phrase); neither the answer nor the matched text is kept.
*/
export function consultationEvidenceCardTelemetry(
state: ConsultationRuntimeState,
answer: string,
): EvidenceCardTelemetry | undefined {
const card = state.evidenceCard;
if (!card || state.evidenceCardChars === undefined) return undefined;
return {
domains: card.domains.slice(0, 2),
cardVersion: card.card_version,
cardChars: state.evidenceCardChars,
cardTokenEstimate: evidenceCardTokenEstimate(state.evidenceCardChars),
citedFieldIds: citedEvidenceCardFields(card, answer),
feedback: "none",
};
}
/**
* 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,
};
}
/**
* C2/C3 (TASK-rectification-in-chat-step1-20261006, BUG-1248): what the offer
* tool needs from the route. `decide` is the server check (self, product on,
* no case in progress, price); `alreadyOffered` comes from this session's
* stored messages; `revisitQuestion` is the question to look at again with
* the new time.
*/
export type RectificationOfferHook = Readonly<{
decide: () => Promise<RectificationOfferDecision>;
alreadyOffered: boolean;
revisitQuestion: string;
/** This consultation. Direct entry opens from it (BUG-1306). */
sourceSessionId: string;
/**
* Called only for an explicit request while a case is already in progress.
* The rectification session to resume, or null when it cannot be read.
*/
loadResumableSessionId: () => Promise<string | null>;
now?: () => Date;
}>;
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;
/** The session already holds a natal answer (D6: a stored-history fact, not an intent guess). */
followUpTurn?: boolean;
serverChart: ServerChartConsultation;
abortSignal?: AbortSignal;
state: ConsultationRuntimeState;
rectificationOffer?: RectificationOfferHook;
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;
rectificationOffer?: RectificationOfferHook;
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[],
gender: ProfileGender | null,
) {
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, { gender });
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) {
// Server-side full context. The single-domain result used to repeat the
// whole packet under `consultations` (about half of what the model read);
// the model now reads the evidence card view instead, and nothing
// server-side reads the copy.
return { ...consultations[0], ...plan };
}
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 };
}
type FullDomainPlanContext = ReturnType<typeof toModelDomainPlanContext>;
export const CONSULTATION_MODEL_PACKET_VERSION = "consultation-evidence-packet-v3" as const;
/**
* The answer contract the system prompt states its rules against (red line 1):
* status, evidence_contract (available / missing layers, hard blockers,
* answer_policy incl. can_answer_direction / can_answer_precise_timing /
* should_lead_with_limitations, user_facing_limitation), claim_cards and
* rectification, plus the plan fields the prompt names (methodology, domains,
* omitted_domains, consultations for several domains). The audit table, the
* varga and western spectra, must_use_layers and presentation stay
* server-side (D3).
*/
function modelAnswerContract(contract: unknown) {
const source = evidenceRecord(contract);
return {
available_layers: Array.isArray(source.available_layers) ? source.available_layers : [],
missing_route_layers: Array.isArray(source.missing_route_layers) ? source.missing_route_layers : [],
hard_blockers: Array.isArray(source.hard_blockers) ? source.hard_blockers : [],
answer_policy: evidenceRecord(source.answer_policy),
...(typeof source.user_facing_limitation === "string"
? { user_facing_limitation: source.user_facing_limitation }
: {}),
};
}
/** The card as the model sees it: its facts as the claim cards, plus its meta. */
function evidenceCardClaimCards(card: EvidenceCard) {
return [
{ category: "natal_foundation" as const, source: "server_chart" as const, evidence: card.base.natal },
{ category: "timing" as const, source: "server_workflow" as const, evidence: card.base.timing },
{ category: "domain" as const, source: "server_workflow" as const, evidence: card.domain_sections },
];
}
function evidenceCardMeta(card: EvidenceCard) {
return {
card_version: card.card_version,
domains: card.domains,
backstage: card.meta.backstage,
supplementable_sections: card.meta.supplementable_sections,
...(card.meta.gaps.length ? { gaps: card.meta.gaps } : {}),
};
}
/**
* What the answer-writing model reads (D1): the answer contract and the
* evidence card. The full per-domain result stays in `full` on the server.
*/
export function toModelEvidenceView(full: FullDomainPlanContext, card: EvidenceCard) {
const consultations = "consultations" in full && Array.isArray(full.consultations) ? full.consultations : null;
return {
packet_version: CONSULTATION_MODEL_PACKET_VERSION,
...(typeof full.question === "string" ? { question: full.question } : {}),
route: full.route,
status: full.status,
evidence_contract: modelAnswerContract(full.evidence_contract),
claim_cards: evidenceCardClaimCards(card),
evidence_card: evidenceCardMeta(card),
// `presentation.required_blocks` (raw_structure … technique_audit_table)
// stays server-side for the thinking plan: the answer shape travels with
// the user turn (BUG-1053) and the audit table is never written in the body
// (BUG-298 / BUG-943).
rectification: full.rectification,
success: full.success,
domains: full.domains,
omitted_domains: full.omitted_domains,
...(full.methodology ? { methodology: full.methodology } : {}),
// Several domains: the top level is the most restrictive merge; each
// domain's own contract is kept for per-domain detail. Its chart facts are
// in the one card, not repeated here.
...(consultations
? {
consultations: consultations.map((consultation) => ({
domain: consultation.domain,
route: consultation.route,
status: consultation.status,
evidence_contract: modelAnswerContract(consultation.evidence_contract),
rectification: consultation.rectification,
})),
}
: {}),
};
}
export type ConsultationModelEvidenceView = ReturnType<typeof toModelEvidenceView>;
type EvidenceLookupCache = {
executions: readonly DomainExecution[];
};
function lookupRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function packetEvidence(execution: DomainExecution, category: string) {
const card = execution.modelOutput.claim_cards.find((item) => item.category === category);
return lookupRecord(card?.evidence);
}
function pickDefined(source: Record<string, unknown>, keys: readonly string[]) {
const out: Record<string, unknown> = {};
for (const key of keys) if (source[key] !== undefined) out[key] = source[key];
return Object.keys(out).length ? out : undefined;
}
/**
* One section from this request's calculation, copied from the model
* projection or the engine context exactly as they hold it. `undefined` means
* the calculation did not produce it; nothing is recomputed.
*/
export function readEvidenceLookupSection(
cache: EvidenceLookupCache,
section: EvidenceLookupSection,
): unknown {
const first = cache.executions[0];
if (!first) return undefined;
const natal = packetEvidence(first, "natal_foundation");
const timing = packetEvidence(first, "timing");
const spectrum = lookupRecord(natal.varga_spectrum);
const western = lookupRecord(timing.western_spectrum);
const layers = lookupRecord(first.context.local_layers);
if (section.startsWith("varga:D")) return lookupRecord(spectrum.formal)[section.slice("varga:".length)];
if (section === "varga:research_dn") return spectrum.research_dn;
if (section === "varga:extended") return spectrum.extended;
if (section === "western:natal") return pickDefined(western, ["zodiac", "house_system", "natal", "boundary"]);
if (section.startsWith("western:")) return lookupRecord(western.techniques)[section.slice("western:".length)];
const native = lookupRecord(layers.native_layers);
const nativeLayer = (name: string) => {
const layer = lookupRecord(native[name]);
return Object.keys(layer).length && layer.status !== "blocked" ? layer : undefined;
};
switch (section) {
case "yogas":
case "arudha_padas":
case "gulika":
case "kakshya":
return natal[section];
case "kp_cusps":
// KP stays off every card; when looked up it travels with its boundary.
return natal.kp_cusps === undefined ? undefined : { kp_cusps: natal.kp_cusps, note: EVIDENCE_LOOKUP_KP_NOTE };
case "karakamsha":
case "moon_transit":
return nativeLayer(section);
case "dispositor_chains": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["chains", "boundary"]) : undefined;
}
case "inter_chart_linkage": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["planets", "boundary"]) : undefined;
}
case "argala": {
const layer = nativeLayer(section);
return layer ? pickDefined(layer, ["method", "summary", "houses"]) : undefined;
}
case "transits":
case "chara_dasha":
return timing[section];
case "vimshottari_mahadashas":
return timing.dasha;
case "ashtakavarga":
return pickDefined(lookupRecord(layers.ashtakavarga), ["sav", "bav", "house_scores", "strongest_signs", "weakest_signs"]);
case "shadbala":
return pickDefined(
{ summary: first.context.chart.shadbala, boundary: layers.shadbala_boundary },
["summary", "boundary"],
);
case "chara_karakas": {
const table = lookupRecord(lookupRecord(layers.jaimini).chara_karakas);
return pickDefined(table, ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]);
}
case "domain_thematic_evidence": {
const byDomain: Record<string, unknown> = {};
for (const execution of cache.executions) {
const evidence = packetEvidence(execution, "domain");
if (Object.keys(evidence).length) byDomain[execution.domain] = evidence;
}
return Object.keys(byDomain).length ? byDomain : undefined;
}
default:
return undefined;
}
}
const evidenceLookupInputSchema = z.object({
section: z.enum(EVIDENCE_LOOKUP_SECTIONS),
}).strict();
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`,
product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`,
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`,
};
function offerRecord(
hook: RectificationOfferHook,
decision: Extract<RectificationOfferDecision, { ok: true }>,
status: RectificationOfferRecord["status"],
): RectificationOfferRecord {
return {
status,
creditCost: decision.creditCost,
questionEstimate: decision.questionEstimate,
sourceQuestion: hook.revisitQuestion,
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
};
}
/**
* Explicit request (BUG-1306): the server already decided. A pass records an
* opened offer with no case id yet and tells the stream to open. A case
* already in progress resumes that case. Anything else is one sentence.
*/
async function finishExplicitRectificationRequest(
state: ConsultationRuntimeState,
hook: RectificationOfferHook,
decision: RectificationOfferDecision,
) {
if (decision.ok) {
state.rectificationOffer = offerRecord(hook, decision, "opened");
state.rectificationDirectEntry = {
kind: "new",
sentence: RECTIFICATION_DIRECT_NEW_COPY,
sourceSessionId: hook.sourceSessionId,
};
return {
offered: true as const,
directEntry: "new" as const,
note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`,
};
}
if (decision.reason === "case_in_progress") {
let resumeSessionId: string | null = null;
try {
resumeSessionId = await hook.loadResumableSessionId();
} catch {
resumeSessionId = null;
}
if (resumeSessionId) {
state.rectificationDirectEntry = {
kind: "resume",
sentence: RECTIFICATION_DIRECT_RESUME_COPY,
sourceSessionId: hook.sourceSessionId,
resumeSessionId,
};
return {
offered: false as const,
reason: decision.reason,
directEntry: "resume" as const,
note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`,
};
}
state.rectificationDirectEntry = {
kind: "refused",
sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress };
}
const sentence = decision.reason === "not_self"
? RECTIFICATION_DIRECT_NOT_SELF_COPY
: RECTIFICATION_DIRECT_UNAVAILABLE_COPY;
state.rectificationDirectEntry = {
kind: "refused",
sentence,
sourceSessionId: hook.sourceSessionId,
};
return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
}
/**
* The offer card tool (C3, BUG-1248; direct entry BUG-1306). The server decides
* whether a card may be shown or this turn opens rectification. Never calculates.
* The rule text lives only in RECTIFICATION_OFFER_RULE.
*/
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
return createTool({
id: RECTIFICATION_OFFER_TOOL_ID,
description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.",
inputSchema: z.object({
userAsked: z.boolean().optional(),
explicitRequest: z.boolean().optional(),
}).strict(),
execute: async (input) => {
const hook = ctx.rectificationOffer;
if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled };
if (input.explicitRequest === true && ctx.state.rectificationDirectEntry) {
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (ctx.state.rectificationDirectEntry || ctx.state.rectificationOffer) {
if (ctx.state.rectificationOffer) {
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
}
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (hook.alreadyOffered && input.userAsked !== true && input.explicitRequest !== true) {
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
const decision = await hook.decide();
if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision);
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
ctx.state.rectificationOffer = offerRecord(hook, decision, "offered");
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
},
});
}
export function createConsultationTools(ctx: ConsultationAgentContext) {
let calculation: Promise<ConsultationModelEvidenceView> | null = null;
// Request-scoped: filled when this request's calculation finishes, read by
// the lookup tool, dropped with the request.
let lookupCache: EvidenceLookupCache | 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. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). 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 each domain's own contract in consultations. claim_cards is the evidence card: the base chart (ascendant, house signs, placements, functional benefics/malefics, the D9 summary with dignities, Vargottama and D1/D9 reversals, the running Vimshottari and Narayana periods) and a section per domain; evidence_card lists what else this calculation holds. 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, domain),
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 });
// The bound subject's own optional gender (self: profiles; other: that
// person's chart_profiles row). It only picks the marriage card label
// and checklist line; the engine never receives it.
const subjectGender = ctx.serverChart.gender ?? null;
const modelContext = toModelDomainPlanContext(executions, omittedDomains, subjectGender);
lookupCache = { executions };
const card = buildEvidenceCard(executions.map((execution) => ({
domain: execution.domain,
packet: execution.modelOutput,
context: execution.context,
})), { gender: subjectGender });
const modelView = toModelEvidenceView(modelContext, card);
ctx.state.evidenceCard = card;
ctx.state.evidenceCardChars = JSON.stringify({
claim_cards: modelView.claim_cards,
evidence_card: modelView.evidence_card,
}).length;
ctx.state.modelVisibleChars = JSON.stringify(modelView).length;
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),
followUp: ctx.followUpTurn,
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 modelView;
} 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;
}
},
});
const lookupTool = createTool({
id: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
description: `Read one section of this request's finished chart calculation that is not on the evidence card, for example a varga the card does not carry (varga:D60), a Western layer (western:solar_return), the yoga details, Chara Dasha, Karakamsha, Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit, or KP (still blocked: reference only, never a basis for a conclusion). It never calculates: it returns what run-jyotish-consultation already computed, or status unavailable. At most ${MAX_EVIDENCE_LOOKUPS_PER_TURN} calls per turn; a fourth call is refused. Call it before writing any answer text, and only when the question needs that section; the answer contract and card come from run-jyotish-consultation.`,
inputSchema: evidenceLookupInputSchema,
execute: async (input, context) => {
const startedAt = Date.now();
ctx.state.evidenceLookupCallCount += 1;
const note = "If you had already started writing the answer, continue from where you stopped; never repeat text already written.";
if (ctx.state.evidenceLookupCallCount > MAX_EVIDENCE_LOOKUPS_PER_TURN) {
if (ctx.state.evidenceLookupCallCount === MAX_EVIDENCE_LOOKUPS_PER_TURN + 1) {
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
status: "failed",
durationMs: 0,
failureCode: "lookup_limit_reached",
});
}
return { status: "refused" as const, reason: "lookup_limit_reached", section: input.section, note };
}
if (!lookupCache) {
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
status: "failed",
durationMs: Math.max(0, Date.now() - startedAt),
failureCode: "calculation_not_cached",
});
return { status: "unavailable" as const, reason: "calculation_not_in_request_cache", section: input.section, note };
}
await (context as { writer?: { custom?: (value: unknown) => unknown } } | undefined)?.writer?.custom?.({
type: "data-jyotish-activity",
data: { phase: "answer-composition", label: evidenceLookupActivityLabel(input.section) },
});
const data = readEvidenceLookupSection(lookupCache, input.section);
const found = data !== undefined && data !== null;
appendConsultationRuntimeStep(ctx.state, {
kind: "tool",
name: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
status: found ? "completed" : "failed",
durationMs: Math.max(0, Date.now() - startedAt),
...(found ? {} : { failureCode: "section_not_computed" }),
});
return found
? { status: "ok" as const, section: input.section, data, note }
: { status: "unavailable" as const, reason: "section_not_computed", section: input.section, note };
},
});
return {
"run-jyotish-consultation": consultationTool,
[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID]: lookupTool,
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
};
}
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,
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
};
}
/**
* 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;
},
};
}