read-consultation-evidence returns one closed-enum section (a formal varga, research/extended vargas, a Western layer, yogas, Ashtakavarga, Shadbala, transits, Chara Dasha, arudha, karakas, KP, gulika, kakshya, mahadashas, thematic evidence) from this request's finished calculation, never recalculates, answers unavailable on a cache miss and refuses a second call. The receipt records the step and the write row shows 「正在多看一眼:…」. A lookup after answer text went out keeps the released text whole: a verbatim restart is dropped as it arrives (40-char confirmation), a continuation is kept, and settlement still reads the step that wrote the answer; the lookup runs on the answer clock without resetting it. A length continuation carries the lookup result with the card. BUG-1059: the visible-text transformer's open clause is settled at each tool call, so unpunctuated narration no longer leaks into the answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
221 lines
9.5 KiB
TypeScript
221 lines
9.5 KiB
TypeScript
// Shared harness for natal-route tests that drive the real personal Agent
|
|
// (`getJyotishAgent`, real skill binding, real calculation and lookup tools,
|
|
// real prepareStep, real run clock) over a prompt-recording fake model.
|
|
// Same wiring as consult-single-pass-answer-20260927.test.ts (BUG-1053); the
|
|
// workflow the calculation tool receives is a golden engine capture.
|
|
import { getJyotishAgent } from "../src/mastra/index.ts";
|
|
import { createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
|
|
import {
|
|
consultationContinueMessages,
|
|
natalAnswerShapeInstruction,
|
|
} from "../src/lib/consultation-thinking-plan.ts";
|
|
import { streamAgentResponse } from "../src/lib/stream-agent-response.ts";
|
|
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
|
|
import {
|
|
AGENT_MAX_STEPS,
|
|
AGENT_TIMEOUT_MS,
|
|
CONSULTATION_ANSWER_TIMEOUT_MS,
|
|
consultationNatalPrepareStep,
|
|
consultationStepBudgetReceipt,
|
|
createConsultationAgentContext,
|
|
createConsultationRunClock,
|
|
createConsultationRuntimeState,
|
|
publicConsultationRuntimeSteps,
|
|
} from "../src/mastra/consultation-tools.ts";
|
|
|
|
export type Part = { text?: string; tool?: { name: string; input: Record<string, unknown> } };
|
|
export type Turn = { parts: Part[]; finish: string; delayMs?: number };
|
|
|
|
/** A LanguageModelV2 that plays `turns` in order and records every prompt. */
|
|
export function scriptedModel(turns: Turn[]) {
|
|
const prompts: unknown[][] = [];
|
|
let call = 0;
|
|
const model = {
|
|
specificationVersion: "v2",
|
|
provider: "fake",
|
|
modelId: "fake-natal-agent",
|
|
supportedUrls: {},
|
|
async doGenerate() {
|
|
throw new Error("not used");
|
|
},
|
|
async doStream(options: { prompt: unknown[]; abortSignal?: AbortSignal }) {
|
|
prompts.push(options.prompt);
|
|
const turn = turns[call] ?? { parts: [], finish: "stop" };
|
|
call += 1;
|
|
const signal = options.abortSignal;
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
let textOpen = false;
|
|
for (const [index, part] of turn.parts.entries()) {
|
|
if (turn.delayMs) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (signal?.aborted) return reject(signal.reason);
|
|
const timer = setTimeout(resolve, turn.delayMs);
|
|
signal?.addEventListener("abort", () => {
|
|
clearTimeout(timer);
|
|
reject(signal.reason);
|
|
}, { once: true });
|
|
});
|
|
} catch (error) {
|
|
controller.error(error);
|
|
return;
|
|
}
|
|
}
|
|
if (part.text !== undefined) {
|
|
if (!textOpen) {
|
|
controller.enqueue({ type: "text-start", id: `t${call}` });
|
|
textOpen = true;
|
|
}
|
|
controller.enqueue({ type: "text-delta", id: `t${call}`, delta: part.text });
|
|
}
|
|
if (part.tool) {
|
|
if (textOpen) {
|
|
controller.enqueue({ type: "text-end", id: `t${call}` });
|
|
textOpen = false;
|
|
}
|
|
controller.enqueue({
|
|
type: "tool-call",
|
|
toolCallId: `call-${call}-${index}`,
|
|
toolName: part.tool.name,
|
|
input: JSON.stringify(part.tool.input),
|
|
});
|
|
}
|
|
}
|
|
if (textOpen) controller.enqueue({ type: "text-end", id: `t${call}` });
|
|
controller.enqueue({
|
|
type: "finish",
|
|
finishReason: turn.finish,
|
|
usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 },
|
|
});
|
|
controller.close();
|
|
},
|
|
});
|
|
return { stream };
|
|
},
|
|
};
|
|
return { model, prompts, calls: () => call };
|
|
}
|
|
|
|
export function pieces(text: string, size = 12) {
|
|
return (text.match(new RegExp(`[\\s\\S]{1,${size}}`, "g")) ?? []).map((value) => ({ text: value }));
|
|
}
|
|
|
|
export const publicServerChart = {
|
|
name: "public",
|
|
toolInput: {
|
|
year: 1955, month: 2, day: 24, hour: 19, minute: 15, city: "San Francisco", lat: 37.77, lon: -122.42, tz: -8,
|
|
ayanamsa: "raman" as const, declared_accuracy: "minute" as const, time_source: "aa_rated",
|
|
},
|
|
truth: {
|
|
birthDate: "1955-02-24", reportedBirthTime: "19:15", activeBirthTime: null,
|
|
selectedTimeKind: "reported" as const, birthTimeSource: "reported", birthTimeStatus: "reported",
|
|
placeLabel: "San Francisco", placeCodes: { countryCode: "US", provinceCode: null, cityCode: null, districtCode: null },
|
|
placeId: null, placeType: "city", placeProvider: "profile", latitude: 37.77, longitude: -122.42,
|
|
timezoneId: "America/Los_Angeles", timezoneSource: "profile", timezoneOffset: -8,
|
|
},
|
|
};
|
|
|
|
let seq = 0;
|
|
|
|
/**
|
|
* The natal route's wiring, minus HTTP, auth and billing: the same Agent,
|
|
* prepareStep, run clock, stream options, step-scoped answer, answer-phase
|
|
* hand-over, continuation builder and answer retry as `runAgenticConsultation`.
|
|
*/
|
|
export async function runNatalAgent(
|
|
turns: Turn[],
|
|
options: {
|
|
workflow: Record<string, unknown>;
|
|
theme?: ConsultationDomain;
|
|
question?: string;
|
|
toolPhaseMs?: number;
|
|
answerMs?: number;
|
|
},
|
|
) {
|
|
seq += 1;
|
|
const { model, prompts, calls } = scriptedModel(turns);
|
|
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
|
|
const clock = createConsultationRunClock({
|
|
toolPhaseMs: options.toolPhaseMs ?? AGENT_TIMEOUT_MS,
|
|
answerMs: options.answerMs ?? CONSULTATION_ANSWER_TIMEOUT_MS,
|
|
answerReady: () => state.consultationToolCompleted,
|
|
});
|
|
let workflowRuns = 0;
|
|
const answerSignals: AbortSignal[] = [];
|
|
const agentContext = createConsultationAgentContext({
|
|
userId: "u", sessionId: "s", requestId: `natal-agent-${seq}`, consultationMode: "verified_chart",
|
|
theme: options.theme ?? "parents", serverChart: publicServerChart as never, abortSignal: clock.toolSignal, state,
|
|
runWorkflow: async () => {
|
|
workflowRuns += 1;
|
|
return structuredClone(options.workflow) as never;
|
|
},
|
|
});
|
|
const agent = getJyotishAgent({ id: `fake-${seq}`, model } as never, agentContext);
|
|
const question = options.question ?? "我和父母关系如何";
|
|
const baseMessages = [{ role: "user" as const, content: `${natalAnswerShapeInstruction()}\n问题:${question}` }];
|
|
const streamOptions = { runId: `run-${seq}`, maxSteps: AGENT_MAX_STEPS, abortSignal: clock.loopSignal };
|
|
const natalStreamOptions = { ...streamOptions, prepareStep: consultationNatalPrepareStep };
|
|
const continuations: unknown[] = [];
|
|
let completed: string | null = null;
|
|
let charges = 0;
|
|
let errored: unknown = null;
|
|
const result = await agent.stream(baseMessages as never, natalStreamOptions as never);
|
|
const response = streamAgentResponse({
|
|
runId: `run-${seq}`,
|
|
requestId: `req-${seq}`,
|
|
state,
|
|
stream: result.fullStream as ReadableStream<unknown>,
|
|
requireTool: true,
|
|
stepScopedAnswer: true,
|
|
onAnswerPhase: () => { answerSignals.push(clock.answerSignal()); },
|
|
pass4Mode: "verified_chart",
|
|
retryForAnswer: async (retryHint) => {
|
|
const retried = await agent.stream([
|
|
...baseMessages,
|
|
{ role: "user" as const, content: `服务器计算已经完成,但上一轮没有输出任何回答文本。请重新取回本次计算结果,然后直接给出回答。${retryHint ? `\n${retryHint}` : ""}` },
|
|
] as never, { ...natalStreamOptions, abortSignal: clock.answerSignal() } as never);
|
|
return retried.fullStream as ReadableStream<unknown>;
|
|
},
|
|
continueAfterLength: async (output, evidence) => {
|
|
continuations.push(evidence);
|
|
answerSignals.push(clock.answerSignal());
|
|
const continued = await agent.stream(
|
|
consultationContinueMessages(baseMessages, output, evidence) as never,
|
|
{ ...streamOptions, abortSignal: clock.answerSignal() } as never,
|
|
);
|
|
return continued.fullStream as ReadableStream<unknown>;
|
|
},
|
|
toolStatus: () => "ready",
|
|
receipt: () => ({
|
|
runId: `run-${seq}`,
|
|
runtime: "mastra-agentic",
|
|
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
|
|
steps: publicConsultationRuntimeSteps(state),
|
|
stepBudget: consultationStepBudgetReceipt(state),
|
|
workflow: state.workflowReceipt ?? { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] },
|
|
techniqueTruth: "unknown",
|
|
}) as never,
|
|
onComplete: (output) => { completed = output; charges += 1; },
|
|
onError: (error) => { errored = error; },
|
|
});
|
|
const events: Array<{ type: string; code?: string; text?: string; label?: string; phase?: string; receipt?: unknown }> = [];
|
|
const parser = createNdjsonParser((event) => events.push(event as never));
|
|
parser.finish(await response.text());
|
|
const answer = events.filter((event) => event.type === "answer.delta").map((event) => event.text ?? "").join("");
|
|
const terminal = events.filter((event) => event.type === "run.completed" || event.type === "run.failed");
|
|
return {
|
|
state, events, answer, terminal, prompts, calls: calls(), continuations, workflowRuns, clock, answerSignals,
|
|
completed: completed as string | null, charges, errored,
|
|
};
|
|
}
|
|
|
|
/** Index of the first prompt that already contains a tool result for `toolName`. */
|
|
export function firstPromptWithToolResult(prompts: unknown[][], toolName: string) {
|
|
return prompts.findIndex((prompt) => prompt.some((message) => {
|
|
const value = message as { role?: string; content?: unknown };
|
|
return value.role === "tool" && JSON.stringify(value.content).includes(toolName);
|
|
}));
|
|
}
|