feat(consult): one read-only evidence lookup per turn; settle open clauses at tool calls (BUG-1059)
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
147ebc1789
commit
cb3ee55837
@@ -0,0 +1,316 @@
|
||||
// TASK-consult-evidence-card-20260927 red line 4 and T5.
|
||||
//
|
||||
// Red line 4 (BUG-1053 stays fixed with the card): the model call that writes
|
||||
// the answer has the evidence card in its prompt; narration before a tool call
|
||||
// is never answer text; a non-`stop` answer step is `answer_truncated` and not
|
||||
// charged; a length continuation carries the card.
|
||||
//
|
||||
// T5 (D8): one read-only lookup of a section outside the card, from this
|
||||
// request's calculation only, at most once per turn. A lookup during the
|
||||
// answer phase must not cut or repeat released text and must not reset the
|
||||
// 110 s tool / 70 s answer clocks.
|
||||
//
|
||||
// Real `getJyotishAgent` + prompt-recording fake model; the calculation is the
|
||||
// golden engine capture of a public AA chart (AGENTS §7.4).
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { REPORT_HEADING } from "../src/lib/consultation-thinking-plan.ts";
|
||||
import { LOOKUP_RESTART_MATCH_CHARS } from "../src/lib/stream-agent-response.ts";
|
||||
import { evidenceLookupActivityLabel } from "../src/lib/consultation-activity-labels.ts";
|
||||
import {
|
||||
CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID,
|
||||
consultationNatalPrepareStep,
|
||||
createConsultationRuntimeState,
|
||||
createConsultationTools,
|
||||
MAX_EVIDENCE_LOOKUPS_PER_TURN,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
import {
|
||||
consultationWorkflowResponseSchema,
|
||||
toAgentConsultationContext,
|
||||
toModelOutput,
|
||||
} from "../src/mastra/consultation-workflow.ts";
|
||||
import {
|
||||
firstPromptWithToolResult,
|
||||
pieces,
|
||||
publicServerChart,
|
||||
runNatalAgent,
|
||||
type Turn,
|
||||
} from "./consult-natal-agent-test-support.ts";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const golden = JSON.parse(readFileSync(
|
||||
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as { charts: Array<{ id: string; workflow: Json }> };
|
||||
const workflow = golden.charts.find((chart) => chart.id === "steve_jobs")!.workflow;
|
||||
const modules = (workflow.chart as Json).modules as Json;
|
||||
// Facts only the calculation carries, read from the engine payload.
|
||||
const PD_START = String(((modules.dasha_sub_periods as Json).pratyantar_dasha_timeline as Json & { current: Json }).current.start);
|
||||
const NARAYANA_SIGN = String(((modules.narayana_dasha as Json).current_dasha as Json & { md: Json }).md.sign);
|
||||
const D60_LAGNA = String((((modules.varga_spectrum as Json).formal as Json).D60 as Json).lagna);
|
||||
|
||||
const PRE_TOOL = "我先排一下盘,稍等。";
|
||||
const LOOKUP_NARRATION = "我再看一眼 D60。";
|
||||
const OPENER = "你和父母这条线,表面是各过各的,底下一直在较劲:你要自己说了算,他们要你稳。这不是谁对谁错,是同一件事的两种护法,所以叫「隔空守护」。";
|
||||
const ANSWER = [
|
||||
OPENER,
|
||||
"",
|
||||
`## ${REPORT_HEADING.question}`,
|
||||
"关系能缓,但先换说话方式,再谈大事。",
|
||||
"",
|
||||
`## ${REPORT_HEADING.support}`,
|
||||
"四宫的主星落在十宫,家里的事常被你当成要办成的事来处理。",
|
||||
"",
|
||||
`## ${REPORT_HEADING.timing}`,
|
||||
"这几个月先试着每周打一次电话,不急着谈结论。",
|
||||
"",
|
||||
`## ${REPORT_HEADING.action}`,
|
||||
"- **这周**:约他们吃一顿饭,只聊近况。",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// The part of the answer written before a mid-answer lookup: past the first
|
||||
// heading, so it has already been released to the client.
|
||||
const HEAD_CHARS = 120;
|
||||
|
||||
const calcCall = (text?: string): Turn => ({
|
||||
parts: [
|
||||
...(text ? [{ text }] : []),
|
||||
{ tool: { name: "run-jyotish-consultation", input: { question: "我和父母关系如何", domains: ["parents"] } } },
|
||||
],
|
||||
finish: "tool-calls",
|
||||
});
|
||||
|
||||
const lookupCall = (section: string, text?: string): Turn => ({
|
||||
parts: [
|
||||
...(text ? pieces(text) : []),
|
||||
{ tool: { name: CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID, input: { section } } },
|
||||
],
|
||||
finish: "tool-calls",
|
||||
});
|
||||
|
||||
const run = (turns: Turn[], options: { toolPhaseMs?: number; answerMs?: number } = {}) => runNatalAgent(turns, { workflow, ...options });
|
||||
|
||||
function toolResultText(prompt: unknown[] | undefined, toolName: string) {
|
||||
return JSON.stringify((prompt ?? []).filter((message) => {
|
||||
const value = message as { role?: string; content?: unknown };
|
||||
return value.role === "tool" && JSON.stringify(value.content).includes(toolName);
|
||||
}));
|
||||
}
|
||||
|
||||
test("the answer-writing call sees the evidence card, not the full result (red line 4)", async () => {
|
||||
const result = await run([calcCall(PRE_TOOL), { parts: pieces(ANSWER), finish: "stop" }]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.calls, 2, "no second, blind writer");
|
||||
const writer = firstPromptWithToolResult(result.prompts, "run-jyotish-consultation");
|
||||
assert.equal(writer, 1);
|
||||
const card = toolResultText(result.prompts[writer], "run-jyotish-consultation");
|
||||
assert.ok(card.includes("evidence-card-v1"), "the card reaches the writer");
|
||||
assert.ok(card.includes(PD_START), "with the engine's pratyantar start");
|
||||
assert.ok(card.includes(NARAYANA_SIGN), "and the running Narayana sign");
|
||||
assert.ok(card.includes("can_answer_direction"), "and the answer contract");
|
||||
for (const absent of ["technique_audit_table", "western_spectrum", "research_dn"]) {
|
||||
assert.doesNotMatch(card, new RegExp(`${absent}\\\\*"\\s*:`), `${absent} stays server-side`);
|
||||
}
|
||||
assert.equal(result.answer, ANSWER);
|
||||
assert.equal(result.answer.includes(PRE_TOOL), false, "pre-tool narration is not the answer");
|
||||
assert.equal(result.charges, 1);
|
||||
});
|
||||
|
||||
test("a non-stop answer step with the card is still truncated and not charged (red line 4)", async () => {
|
||||
const result = await run([calcCall(), { parts: pieces(ANSWER.slice(0, 200)), finish: "content-filter" }]);
|
||||
assert.deepEqual(result.terminal.map((event) => `${event.type}:${event.code ?? ""}`), ["run.failed:answer_truncated"]);
|
||||
assert.equal(result.charges, 0);
|
||||
});
|
||||
|
||||
test("a length continuation carries the card (red line 4)", async () => {
|
||||
const cut = ANSWER.indexOf(`## ${REPORT_HEADING.timing}`);
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
{ parts: pieces(ANSWER.slice(0, cut)), finish: "length" },
|
||||
{ parts: pieces(ANSWER.slice(cut)), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.continuations.length, 1);
|
||||
const continuation = JSON.stringify(result.prompts[2]);
|
||||
assert.ok(continuation.includes("evidence-card-v1"));
|
||||
assert.ok(continuation.includes(PD_START));
|
||||
assert.equal(result.completed, ANSWER);
|
||||
});
|
||||
|
||||
test("step 0 still exposes only the calculation tool; later steps may use the lookup", () => {
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }).activeTools, ["run-jyotish-consultation"]);
|
||||
assert.equal("activeTools" in consultationNatalPrepareStep({ stepNumber: 1 }), false);
|
||||
assert.equal(MAX_EVIDENCE_LOOKUPS_PER_TURN, 1);
|
||||
});
|
||||
|
||||
test("a lookup before the answer returns the section from the request cache and the answer is written once", async () => {
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("varga:D60", LOOKUP_NARRATION),
|
||||
{ parts: pieces(ANSWER), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.workflowRuns, 1, "the lookup never recalculates");
|
||||
assert.equal(result.answer, ANSWER);
|
||||
assert.equal(result.answer.includes(LOOKUP_NARRATION), false, "narration around the lookup is not answer text");
|
||||
const writer = firstPromptWithToolResult(result.prompts, CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID);
|
||||
assert.equal(writer, 2);
|
||||
const lookup = toolResultText(result.prompts[writer], CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID);
|
||||
assert.ok(lookup.includes(D60_LAGNA), "the D60 lagna the engine computed");
|
||||
assert.match(lookup, /status\\*"\s*:\s*\\*"ok/);
|
||||
// Receipt step and the live activity row in plain words.
|
||||
const step = result.state.steps.find((item) => item.name === CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID);
|
||||
assert.equal(step?.kind, "tool");
|
||||
assert.equal(step?.status, "completed");
|
||||
assert.ok(result.events.some((event) => event.type === "activity" && event.label === evidenceLookupActivityLabel("varga:D60")));
|
||||
assert.equal(evidenceLookupActivityLabel("varga:D60"), "正在多看一眼:D60 分盘…");
|
||||
assert.equal(result.state.evidenceLookupCallCount, 1);
|
||||
assert.equal(result.charges, 1);
|
||||
});
|
||||
|
||||
test("a second lookup in the same turn is refused", async () => {
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("varga:D60"),
|
||||
lookupCall("western:solar_return"),
|
||||
{ parts: pieces(ANSWER), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
const second = toolResultText(result.prompts[3], CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID);
|
||||
assert.ok(second.includes("lookup_limit_reached"));
|
||||
assert.equal(result.state.evidenceLookupCallCount, 2);
|
||||
assert.deepEqual(
|
||||
result.state.steps.filter((item) => item.name === CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID).map((item) => item.status),
|
||||
["completed", "failed"],
|
||||
);
|
||||
assert.equal(result.answer, ANSWER);
|
||||
});
|
||||
|
||||
test("a lookup with no calculation in the request cache returns unavailable and calculates nothing", async () => {
|
||||
let workflowRuns = 0;
|
||||
const state = createConsultationRuntimeState();
|
||||
const tools = createConsultationTools({
|
||||
userId: "u", sessionId: "s", requestId: "lookup-miss", consultationMode: "verified_chart",
|
||||
serverChart: publicServerChart as never, state,
|
||||
runWorkflow: async () => {
|
||||
workflowRuns += 1;
|
||||
return structuredClone(workflow) as never;
|
||||
},
|
||||
});
|
||||
const miss = await tools[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID].execute!({ section: "varga:D60" } as never, {} as never) as Json;
|
||||
assert.equal(miss.status, "unavailable");
|
||||
assert.equal(miss.reason, "calculation_not_in_request_cache");
|
||||
assert.equal(workflowRuns, 0);
|
||||
assert.equal(state.steps.at(-1)?.name, CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID);
|
||||
assert.equal(state.steps.at(-1)?.status, "failed");
|
||||
});
|
||||
|
||||
test("the lookup returns exactly the projected section it names", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
const tools = createConsultationTools({
|
||||
userId: "u", sessionId: "s", requestId: "lookup-hit", consultationMode: "verified_chart",
|
||||
serverChart: publicServerChart as never, state,
|
||||
runWorkflow: async () => structuredClone(workflow) as never,
|
||||
});
|
||||
await tools["run-jyotish-consultation"].execute!({ question: "q", domains: ["parents"] } as never, { writer: { custom: async () => {} } } as never);
|
||||
const hit = await tools[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID].execute!({ section: "varga:D60" } as never, { writer: { custom: async () => {} } } as never) as Json;
|
||||
const packet = toModelOutput(toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow))));
|
||||
const natal = packet.claim_cards.find((card) => card.category === "natal_foundation")!.evidence as Json;
|
||||
assert.equal(hit.status, "ok");
|
||||
assert.deepEqual(hit.data, ((natal.varga_spectrum as Json).formal as Json).D60);
|
||||
});
|
||||
|
||||
test("a lookup after answer text went out: a verbatim restart is dropped, the answer is whole and settles on stop", async () => {
|
||||
const head = ANSWER.slice(0, HEAD_CHARS);
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("yogas", head),
|
||||
// The model starts the whole answer over after the lookup.
|
||||
{ parts: pieces(ANSWER), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.answer, ANSWER, "released text is neither cut nor repeated");
|
||||
assert.equal(result.completed, ANSWER);
|
||||
assert.equal(result.charges, 1);
|
||||
assert.ok(result.state.steps.some((step) => step.name === "answer-restart-dropped"));
|
||||
assert.equal(result.state.composeFinishReason, "stop");
|
||||
});
|
||||
|
||||
test("a lookup after answer text went out: a continuation is kept as written", async () => {
|
||||
const head = ANSWER.slice(0, HEAD_CHARS);
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("yogas", head),
|
||||
{ parts: pieces(ANSWER.slice(HEAD_CHARS)), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.answer, ANSWER);
|
||||
assert.equal(result.state.steps.some((step) => step.name === "answer-restart-dropped"), false);
|
||||
});
|
||||
|
||||
test("a step that only opens like the released text is not mistaken for a restart", async () => {
|
||||
const head = ANSWER.slice(0, HEAD_CHARS);
|
||||
const shared = OPENER.slice(0, LOOKUP_RESTART_MATCH_CHARS - 10);
|
||||
const tail = `${shared}——补一句:格局明细里没有新的东西。`;
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("yogas", head),
|
||||
{ parts: pieces(tail), finish: "stop" },
|
||||
]);
|
||||
assert.equal(result.answer, `${head}${tail}`);
|
||||
assert.equal(result.state.steps.some((step) => step.name === "answer-restart-dropped"), false);
|
||||
});
|
||||
|
||||
test("a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase", async () => {
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
{ ...lookupCall("varga:D60"), delayMs: 40 },
|
||||
{ parts: pieces(ANSWER, 6), finish: "stop", delayMs: 25 },
|
||||
], { answerMs: 300 });
|
||||
assert.deepEqual(result.terminal.map((event) => `${event.type}:${event.code ?? ""}`), ["run.failed:answer_truncated"]);
|
||||
assert.equal(result.charges, 0);
|
||||
assert.equal(result.answerSignals.length, 1, "the answer clock started once");
|
||||
assert.ok(result.state.steps.some((step) => step.kind === "abort" && step.name === "compose-abort"));
|
||||
});
|
||||
|
||||
test("a lookup in the answer phase is not cut by the tool phase's deadline", async () => {
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
{ ...lookupCall("varga:D60"), delayMs: 30 },
|
||||
{ parts: pieces(ANSWER, 6), finish: "stop", delayMs: 12 },
|
||||
], { toolPhaseMs: 150, answerMs: 5_000 });
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.clock.toolSignal.aborted, true, "the tool phase did expire");
|
||||
assert.equal(result.answer, ANSWER);
|
||||
});
|
||||
|
||||
test("BUG-1059: narration without closing punctuation before a tool call never leaks into the answer", async () => {
|
||||
// The visible-text transformer held the open clause ("…:") across the tool
|
||||
// call and emitted it glued to the next step's first sentence.
|
||||
const openCalc = "我先排一下盘:";
|
||||
const openLookup = "再看一眼 D60 分盘,";
|
||||
const result = await run([
|
||||
calcCall(openCalc),
|
||||
lookupCall("varga:D60", openLookup),
|
||||
{ parts: pieces(ANSWER), finish: "stop" },
|
||||
]);
|
||||
assert.deepEqual(result.terminal.map((event) => event.type), ["run.completed"]);
|
||||
assert.equal(result.answer, ANSWER);
|
||||
assert.equal(result.answer.includes("排一下盘"), false);
|
||||
assert.equal(result.answer.includes("再看一眼"), false);
|
||||
});
|
||||
|
||||
test("BUG-1059: an open clause of released answer text before a lookup goes out whole, not cut", async () => {
|
||||
// The head ends mid-sentence; that fragment is answer text, not narration.
|
||||
const head = ANSWER.slice(0, HEAD_CHARS);
|
||||
assert.doesNotMatch(head.slice(-1), /[。!?.!?\n]/, "the head really ends mid-clause");
|
||||
const result = await run([
|
||||
calcCall(),
|
||||
lookupCall("yogas", head),
|
||||
{ parts: pieces(ANSWER.slice(HEAD_CHARS)), finish: "stop" },
|
||||
]);
|
||||
assert.equal(result.answer, ANSWER);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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);
|
||||
}));
|
||||
}
|
||||
@@ -275,7 +275,11 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
|
||||
// 原因: BUG-1053 删除 compose 与「丢弃主循环正文」
|
||||
assert.doesNotMatch(stream, /composeAnswer|drainSpoken/);
|
||||
assert.match(stream, /stepScopedAnswer/);
|
||||
assert.match(stream, /continueAfterLength\(pendingAnswer\(\), calculationEvidence\)/);
|
||||
// 原值: /continueAfterLength\(pendingAnswer\(\), calculationEvidence\)/
|
||||
// 新值: 续写带 evidence = 计算结果(数据卡),模型本轮用过补取时再并上 evidence_lookup
|
||||
// 原因: TASK-consult-evidence-card-20260927 T5,续写不能丢掉补取到的那一段
|
||||
assert.match(stream, /continueAfterLength\(pendingAnswer\(\), evidence\)/);
|
||||
assert.match(stream, /\{ \.\.\.calculationEvidence, evidence_lookup: lookupEvidence \}/);
|
||||
assert.match(stream, /answer-continue/);
|
||||
});
|
||||
|
||||
@@ -299,7 +303,10 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
|
||||
assert.doesNotMatch(agenticBranch, /await runConsultationWorkflow/);
|
||||
assert.doesNotMatch(agenticBranch, /JSON\.stringify\(toolInput\)/);
|
||||
assert.match(tools, /\(ctx\.runWorkflow \?\? runConsultationWorkflow\)\(toolInput, \{/);
|
||||
assert.match(tools, /return \{ "run-jyotish-consultation": consultationTool \};/);
|
||||
// 原值: /return \{ "run-jyotish-consultation": consultationTool \};/
|
||||
// 新值: 同一个工厂再返回只读补取工具(同请求缓存,每轮一次)
|
||||
// 原因: TASK-consult-evidence-card-20260927 T5(D8)
|
||||
assert.match(tools, /return \{\s*"run-jyotish-consultation": consultationTool,\s*\[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID\]: lookupTool,\s*\};/);
|
||||
// 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳
|
||||
// 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期
|
||||
// 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。
|
||||
|
||||
Reference in New Issue
Block a user