窗口 Agent 注入不含本命骨架的方法块;tripwire abort 走 skill_binding_failed; 无工具但有正文降级交付;应期问题仍先调工具。BUG-957 等窗口线验证后再做。
312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
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();
|
|
|
|
for (const heading of ["商业运行时路由", "关联技法完整调取", "强制工作流", "五层硬约束", "核心方法论", "注意事项", "commercial_skill_truth_overlay"]) {
|
|
assert.ok(body.includes(heading), heading);
|
|
assert.ok(bound.includes(heading), heading);
|
|
}
|
|
for (const phrase of ["全谱系真实调用", "P0/P1 观察层", "网页对话的技法审计表由界面折叠展示", "静态分析10步"]) {
|
|
assert.ok(bound.includes(phrase), phrase);
|
|
}
|
|
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("the system block binds the shared method sections verbatim from the package", () => {
|
|
const shared = sharedConsultationMethodMarkdown();
|
|
const opened = jyotishSkillMethodBlock.indexOf("<jyotish-shared-method>");
|
|
const closed = jyotishSkillMethodBlock.indexOf("</jyotish-shared-method>");
|
|
assert.ok(opened >= 0 && closed > opened);
|
|
const boundShared = jyotishSkillMethodBlock.slice(
|
|
opened + "<jyotish-shared-method>".length,
|
|
closed,
|
|
).trim();
|
|
assert.equal(boundShared, shared.trim());
|
|
assert.match(shared, /Full-Spectrum Invocation Contract/);
|
|
assert.ok(shared.includes(readFileSync(resolve(jyotishSkillPackage.resolvedPath, "references/event_judgment_skeleton.md"), "utf8").trim()));
|
|
});
|