Files
Jyotisha/frontend/tests/skill-binding.test.ts
T
Jesse_ChenandClaude Opus 5.5 c76fdda7ab
Independent Staging Quality Gate / validate (push) Successful in 14m35s
Independent Staging Quality Gate / publish (push) Successful in 3m37s
refactor(consult): chat binds the method's truth boundaries, not the engine manual (BUG-1256 step 2)
The method block bound into every chat turn quoted most of SKILL.md (benchmark
scores, CLI flags, oracle queues, file paths) plus the shared method, whose
output contract asks for JSON verdicts, A/B/C/D confidence, audit tables, raw
data and web verification - the opposite of the chat shape. Chat now quotes
only the truth-boundary sections and adds CHAT_METHOD_BOUNDARIES for the
limits from the dropped sections it still has to keep. SKILL.md and the shared
method file are unchanged for reports and skill_read.

Natal system prompt 55,242 -> 26,591 characters. DeepSeek A/B on public
golden charts (flash 10 questions): input 24,560 -> 14,025 tokens, 37.8 ->
30.3 s, no regression in automatic checks or reading; a high-rigor request
still says plainly that no external check was done. Full suite 4970 / fail
24, identical to b8385adb; build keeps / Static.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 08:48:04 +08:00

328 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import test from "node:test";
import { Agent } from "@mastra/core/agent";
import { sharedConsultationMethodMarkdown } from "../src/lib/consultation-methodology.ts";
import {
BOUND_METHOD_MARKER,
jyotishSkillBinding,
jyotishSkillBoundProcessor,
jyotishSkillMethodBlock,
jyotishSkillMethodCoreBlock,
jyotishSkillPackage,
jyotishSkillRuntimePath,
} from "../src/mastra/skill-binding.ts";
const model = { specificationVersion: "v2", provider: "probe", modelId: "probe" } as never;
function probeAgent(options: Record<string, unknown>) {
return new Agent({ id: "skill-binding-probe", name: "Probe", model, instructions: "x", ...options } as never);
}
function liveSkillBody() {
const live = readFileSync(resolve(jyotishSkillPackage.resolvedPath, "SKILL.md"), "utf8");
return live.slice(live.indexOf("---", 3) + 4).trim();
}
function boundMethodText() {
const opened = jyotishSkillMethodBlock.indexOf(`<jyotish-skill name="${jyotishSkillPackage.name}"`);
const closed = jyotishSkillMethodBlock.lastIndexOf("</jyotish-skill>");
assert.ok(opened >= 0 && closed > opened);
return jyotishSkillMethodBlock.slice(jyotishSkillMethodBlock.indexOf("\n", opened) + 1, closed).trim();
}
test("the method the model follows is the live skill, not a hashed snapshot", () => {
const body = liveSkillBody();
const bound = boundMethodText();
assert.match(jyotishSkillMethodBlock, new RegExp(`name="${jyotishSkillPackage.name}"`));
assert.doesNotMatch(jyotishSkillMethodBlock, /<jyotish-skill name="[^"]+" version="/);
for (const line of bound.split("\n")) {
if (line.length === 0) continue;
assert.ok(body.includes(line), line.slice(0, 80));
}
assert.ok(jyotishSkillRuntimePath.endsWith(jyotishSkillPackage.name));
assert.equal(jyotishSkillRuntimePath.includes("/versions/"), false);
assert.equal(existsSync(join(jyotishSkillRuntimePath, "versions")), false);
assert.equal(
realpathSync(join(jyotishSkillRuntimePath, "SKILL.md")),
realpathSync(join(jyotishSkillPackage.resolvedPath, "SKILL.md")),
);
});
test("the bound method is the runtime excerpt, not the maintainer manual", () => {
// Independent source: the commercial SKILL.md still contains both the
// answering contract and the repo-construction notes. Binding the whole
// file is what pushed Mastra past its 500-line warning; the excerpt keeps
// the contract and drops the notes.
const body = liveSkillBody();
const bound = boundMethodText();
// 原值: 摘录含「商业运行时路由 / 关联技法完整调取 / 强制工作流 / 五层硬约束 / 核心方法论 / 注意事项 / commercial_skill_truth_overlay」与「全谱系真实调用」「静态分析10步」
// 新值: 只含诚实边界几节(运行时路由正文、truth overlay、Ashtottari、P0/P1 观察层、三层验证法);工程与报告用的章节留在技能目录
// 原因: 2026-10-07 聊天提示词精简(BUG-1256 第二步):方法书只摘诚实边界几节,工程记录、跑分、脚本、文件路径和与聊天形状冲突的输出合同不再进每轮系统提示
for (const heading of ["商业运行时路由", "关联技法完整调取", "核心方法论"]) {
assert.ok(body.includes(heading), heading);
assert.ok(bound.includes(heading), heading);
}
for (const phrase of ["P0/P1 观察层", "网页对话的技法审计表由界面折叠展示", "候选出生时间不得写成 confirmed", "Skill truth overlay 硬边界", "三层验证法"]) {
assert.ok(bound.includes(phrase), phrase);
}
for (const heading of ["强制工作流", "五层硬约束", "注意事项", "强制规范速查", "当前最硬的未闭环点", "MEVG 强制外部验证门控", "Transit Actionable Output", "全谱系真实调用", "静态分析10步"]) {
assert.ok(body.includes(heading), heading);
assert.equal(bound.includes(heading), false, heading);
}
for (const heading of ["施工判断原则", "冲顶路线", "37大子命令", "验证与错题体系", "开源复用边界冻结"]) {
assert.ok(body.includes(heading), heading);
assert.equal(bound.includes(heading), false, heading);
}
assert.ok(body.includes("~/.workbuddy/skills"));
assert.equal(bound.includes("~/.workbuddy/skills"), false);
assert.ok(bound.split("\n").length < 500, `${bound.split("\n").length} lines`);
assert.ok(bound.length < body.length * 0.7, `${bound.length} of ${body.length}`);
});
test("binding the method costs a fraction of activating the skill", async () => {
// What activation would have sent, measured rather than assumed: Mastra answers
// it with the entrypoint plus a flat listing of every file in the package, and
// that listing is the majority of the bytes. Nothing carries over between
// requests, so it was resent on every turn of every conversation.
const activation = probeAgent({ skills: [jyotishSkillRuntimePath] });
const skillTool = (await activation.getToolsForExecution({ runId: "probe" })).skill as {
execute: (input: unknown, context: unknown) => Promise<unknown>;
};
const activated = await skillTool.execute(
{ name: jyotishSkillPackage.name },
{ runId: "probe" } as never,
);
const activationBytes = Buffer.byteLength(
typeof activated === "string" ? activated : JSON.stringify(activated),
"utf8",
);
const boundBytes = Buffer.byteLength(jyotishSkillMethodBlock, "utf8");
assert.ok(activationBytes > boundBytes * 2, `${activationBytes} vs ${boundBytes}`);
assert.ok(boundBytes < activationBytes * 0.45, `${boundBytes} of ${activationBytes}`);
});
test("agents that bind the method are not given a tool to load it", async () => {
const bound = probeAgent(jyotishSkillBinding());
const toolNames = Object.keys(await bound.getToolsForExecution({ runId: "probe" }));
assert.deepEqual(toolNames, ["skill_read"]);
// The skill is still declared, so the references the method names by path stay
// reachable for the one question the delivered sections do not cover.
assert.equal(
(await bound.listSkills()).some((skill) => skill.name === jyotishSkillPackage.name),
true,
);
});
test("a prompt assembled without the method stops the run instead of answering", () => {
const abortReasons: string[] = [];
const abort = ((reason?: string) => {
abortReasons.push(reason ?? "");
throw new Error("aborted");
}) as never;
const step = (system: unknown[]) => jyotishSkillBoundProcessor.processInputStep({
messageList: { getAllSystemMessages: () => system },
abort,
} as never);
assert.throws(() => step([{ role: "system", content: "you are a guide" }]), /aborted/);
assert.match(abortReasons[0], /not bound into the system prompt/);
// A prompt that does carry the method, and a prompt that cannot be read at all,
// both pass: this guard exists to catch a missing method, not to become a new
// way for a run to fail.
assert.doesNotThrow(() => step([{ role: "system", content: jyotishSkillMethodBlock }]));
assert.doesNotThrow(() => step([{ role: "system", content: jyotishSkillMethodCoreBlock }]));
assert.doesNotThrow(() => step([]));
});
function agentConstructorBodies(source: string): string[] {
const bodies: string[] = [];
const needle = "new Agent(";
let search = 0;
while (true) {
const start = source.indexOf(needle, search);
if (start < 0) break;
const openParen = start + needle.length - 1;
let depth = 0;
let inStr: string | null = null;
let escaped = false;
let i = openParen;
for (; i < source.length; i += 1) {
const ch = source[i];
if (inStr) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === inStr) inStr = null;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
inStr = ch;
continue;
}
if (ch === "(") depth += 1;
else if (ch === ")") {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
}
bodies.push(source.slice(start, i));
search = i;
}
return bodies;
}
function constTemplateBody(file: string, name: string): string {
return new RegExp(`(?:const|let) ${name} = \`([\\s\\S]*?)\`;`).exec(file)?.[1] ?? "";
}
function instructionSource(agentBody: string, file: string): string {
const named = /instructions:\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(agentBody);
let text = named
? `${agentBody}\n${constTemplateBody(file, named[1])}`
: (/instructions:\s*`([\s\S]*?)`/.exec(agentBody)?.[1] ?? agentBody);
for (const match of text.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
text += `\n${constTemplateBody(file, match[1])}`;
}
return text;
}
function carriesBoundMethodMarker(text: string): boolean {
return text.includes("jyotishSkillMethodBlock")
|| text.includes("jyotishSkillMethodCoreBlock")
|| text.includes(BOUND_METHOD_MARKER)
|| text.includes("<jyotish-skill name=");
}
test("every agent that attaches jyotishSkillBinding carries the method marker (BUG-954)", () => {
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const boundAgents = agentConstructorBodies(mastra).filter((body) => body.includes("...jyotishSkillBinding()"));
assert.ok(boundAgents.length >= 3, `expected natal, legacy, and window agents, got ${boundAgents.length}`);
for (const body of boundAgents) {
assert.equal(carriesBoundMethodMarker(instructionSource(body, mastra)), true, body.slice(0, 160));
}
assert.match(jyotishSkillMethodCoreBlock, new RegExp(BOUND_METHOD_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.doesNotMatch(jyotishSkillMethodCoreBlock, /Level 2 report template/);
assert.match(jyotishSkillMethodBlock, /Level 2 report template/);
});
test("window agent input processing does not abort (BUG-954)", async () => {
const { getWindowJyotishAgent } = await import("../src/mastra/index.ts");
const { createConsultationRuntimeState, createWindowConsultationAgentContext } = await import("../src/mastra/consultation-tools.ts");
const model = {
id: "window-binding-probe",
label: "Window probe",
description: "",
creditCost: 1,
isDefault: false,
mode: "openai",
model: "openai/gpt-5-mini",
} as never;
const agent = getWindowJyotishAgent(model, createWindowConsultationAgentContext({
userId: "user",
sessionId: "session",
requestId: "req",
consultationMode: "declared_birth_window",
declaredWindow: {
name: "探针",
toolInput: {
year: 1990, month: 1, day: 2, city: "台北", lat: 25.03, lon: 121.56, tz: 8,
ayanamsa: "raman", rangeStart: "04:00", rangeEnd: "06:00",
},
truth: {
birthDate: "1990-01-02",
birthTimeSource: "family_period",
birthTimePeriod: "morning",
birthTimeStatus: "window",
wrapsMidnight: false,
placeLabel: "台北",
placeCodes: { countryCode: "TW", provinceCode: null, cityCode: null, districtCode: null },
placeId: null,
placeType: "city",
placeProvider: "profile",
timezoneId: "Asia/Taipei",
timezoneSource: "profile",
latitude: 25.03,
longitude: 121.56,
timezoneOffset: 8,
},
},
state: createConsultationRuntimeState(),
}));
const instructions = await agent.getInstructions();
const text = typeof instructions === "string" ? instructions : JSON.stringify(instructions);
assert.match(text, /<jyotish-skill name="/);
assert.doesNotMatch(text, /Level 2 report template/);
const abortReasons: string[] = [];
assert.doesNotThrow(() => jyotishSkillBoundProcessor.processInputStep({
messageList: { getAllSystemMessages: () => [{ role: "system", content: text }] },
abort: ((reason?: string) => {
abortReasons.push(reason ?? "");
throw new Error("aborted");
}) as never,
} as never));
assert.equal(abortReasons.length, 0);
});
test("only chart-answering agents bind the method", () => {
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
const chart = mastra.slice(
mastra.indexOf("const jyotishInstructions"),
mastra.indexOf("const generalJyotishInstructions"),
);
const general = mastra.slice(
mastra.indexOf("const generalJyotishInstructions"),
mastra.indexOf("const dailyStarlanguageInstructions"),
);
const daily = mastra.slice(
mastra.indexOf("const dailyStarlanguageInstructions"),
mastra.indexOf("const birthTimeGuideInstructions"),
);
assert.match(chart, /\.\.\.jyotishSkillBinding\(\)/);
assert.match(chart, /\$\{jyotishSkillMethodBlock\}/);
// 原值: 另切 onboardingInstructions,断言它也不绑方法。
// 新值: onboarding agent 已删除;general 与 daily 仍不绑。
// 原因: 建议问题生成接口下线。
for (const [name, source] of [["general", general], ["daily", daily]] as const) {
assert.doesNotMatch(source, /jyotishSkillBinding|jyotishSkillMethodBlock/, name);
}
});
test("chat binds the method boundaries, not the report-shaped shared method (BUG-1256)", async () => {
// 原值: 系统块逐字绑定共享方法(Full-Spectrum Invocation Contract + event_judgment_skeleton.md)
// 新值: 不再绑定共享方法;改为 <chat-method-boundaries>,用产品自己的话保留聊天仍要守的边界
// 原因: 2026-10-07 聊天提示词精简(BUG-1256 第二步):方法书只摘诚实边界几节,工程记录、跑分、脚本、文件路径和与聊天形状冲突的输出合同不再进每轮系统提示;共享方法要求输出 JSON verdict + A/B/C/D 置信度 + 审计表 + 原始数据 + 联网验证,与聊天形状冲突
const { CHAT_METHOD_BOUNDARIES } = await import("../src/mastra/skill-binding.ts");
for (const block of [jyotishSkillMethodBlock, jyotishSkillMethodCoreBlock]) {
assert.equal(block.includes("<jyotish-shared-method>"), false);
assert.equal(block.includes("Full-Spectrum Invocation Contract"), false);
assert.ok(block.includes(CHAT_METHOD_BOUNDARIES));
}
for (const kept of ["不从一段大运或一次行运直接跳到「会发生」", "Vimshottari 与 Narayana 两套大运同向", "大运起止日期只引用卡上给出的", "不宣称全球第一", "没有做外部引擎比对、全网资料核验和真实案例校准,照实说明"]) {
assert.ok(CHAT_METHOD_BOUNDARIES.includes(kept), kept);
}
// The shared method is still the package's own file, for reports and skill_read.
assert.match(sharedConsultationMethodMarkdown(), /Full-Spectrum Invocation Contract/);
});
test("the chat method block stays small (BUG-1256)", () => {
// It was 25,872 characters, most of the 55k natal system prompt.
assert.ok(jyotishSkillMethodBlock.length < 4_000, `${jyotishSkillMethodBlock.length}`);
assert.ok(jyotishSkillMethodCoreBlock.length < 4_000, `${jyotishSkillMethodCoreBlock.length}`);
});