1565 lines
71 KiB
TypeScript
1565 lines
71 KiB
TypeScript
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;
|
||
},
|
||
};
|
||
}
|