Files
Jyotisha/frontend/src/mastra/skill-binding.ts
T
jesse-ux 5b6abc238b fix(consult): BUG-954~956/958 窗口方法块、abort 分码、合同降级
窗口 Agent 注入不含本命骨架的方法块;tripwire abort 走 skill_binding_failed;
无工具但有正文降级交付;应期问题仍先调工具。BUG-957 等窗口线验证后再做。
2026-09-18 16:37:43 +08:00

187 lines
8.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Processor } from "@mastra/core/processors";
import {
resolveLiveJyotishSkill,
resolveLiveJyotishSkillRuntimePath,
} from "../lib/skill-package-registry.ts";
import { sharedConsultationMethodMarkdown } from "../lib/consultation-methodology.ts";
const skill = resolveLiveJyotishSkill();
export const jyotishSkillPackage = skill;
/**
* Mastra-named alias of the live SKILL.md / references / scripts / assets.
* Not a hashed snapshot: leftover `versions/` trees stay off this path.
*/
export const jyotishSkillRuntimePath = resolveLiveJyotishSkillRuntimePath(skill);
/**
* Headings from the commercial SKILL.md that govern answering a natal chart.
* The rest of that file is a local-agent / maintainer manual (CLI indexes,
* construction rules, celebrity case catalogs) and stays in the package for
* skill_read rather than being stuffed into every consultation prompt.
*/
const RUNTIME_METHOD_HEADINGS = [
"商业运行时路由",
"关联技法完整调取",
"强制工作流",
"五层硬约束",
"强制规则",
"当前最硬的未闭环点",
"核心方法论",
"强制规范速查",
"注意事项",
] as const;
const DROPPED_RUNTIME_SUBHEADINGS = ["开源复用边界冻结"] as const;
function publishedSkillBody(): string {
const raw = readFileSync(resolve(skill.resolvedPath, "SKILL.md"), "utf8");
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(raw);
return (frontmatter ? raw.slice(frontmatter[0].length) : raw).trim();
}
function dropSubheadings(section: string, dropped: readonly string[]): string {
const lines = section.split("\n");
const kept: string[] = [];
let skipping = false;
for (const line of lines) {
if (line.startsWith("### ")) {
skipping = dropped.some((heading) => line.includes(heading));
} else if (line.startsWith("## ")) {
skipping = false;
}
if (!skipping) kept.push(line);
}
return kept.join("\n").trim();
}
/**
* Mastra answers a skill activation with the entrypoint *and* a flat listing of
* every file in the package, which for this package measured 129,651 bytes: the
* method is 30,507 of them and the remaining 99,144 are 1,592 bare paths with no
* description. Nothing in a request carries over to the next one - the agent is
* rebuilt per request, no thread memory is configured, and the replayed history
* is plain question/answer text - so the model re-activated the skill on every
* turn and paid for that listing every time, twice more whenever a retry opened
* a fresh model loop.
*
* Binding the runtime excerpt into the system prompt is what the listing was
* standing in for. It also removes an activation the model could forget, which
* is what `runtime_contract_incomplete` was mostly reporting. The excerpt is
* taken from the live commercial entrypoint the operator maintains.
*/
function boundMethod(): string {
const body = publishedSkillBody();
if (body.length === 0) {
throw new Error(`Skill ${skill.name} has no method body to bind`);
}
const lines = body.split("\n");
const preamble: string[] = [];
const sections: string[][] = [];
let current: string[] | null = null;
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current);
current = [line];
} else if (current) {
current.push(line);
} else {
preamble.push(line);
}
}
if (current) sections.push(current);
const kept = sections.flatMap((section) => {
const heading = section[0] ?? "";
if (!RUNTIME_METHOD_HEADINGS.some((wanted) => heading.includes(wanted))) return [];
const text = dropSubheadings(section.join("\n"), DROPPED_RUNTIME_SUBHEADINGS);
return text.length > 0 ? [text] : [];
});
const excerpt = [...(preamble.join("\n").trim() ? [preamble.join("\n").trim()] : []), ...kept].join("\n\n");
if (excerpt.length === 0) {
throw new Error(`Skill ${skill.name} has no runtime method sections to bind`);
}
return excerpt;
}
export const BOUND_METHOD_MARKER = `<jyotish-skill name="${skill.name}">`;
const NATAL_REPORT_SKELETON = "For career, wealth, marriage, and family answers, present its Level 2 report template in the chat body after a 3-6 sentence spoken reply with no heading (反差(表面 A,底下 B,命名成一个格局)→ 谁在推、谁在修 → 别去应 X 的象、去扮演 Y 的象 → 最多三条短行动,各 ≤ 20 characters; spoken layer ≤ 400 characters; raw structure, six-step houses, Yoga table, timing, synthesis, Technique Audit Table, then a short modern wrap). ";
function methodBlock(reportSkeleton: string) {
return `The jyotish-vedic-astrology skill is already loaded. Its runtime method is quoted below from the live skill the operator maintains; there is no activation step, no hashed package, and no tool that loads it. Follow this method and its truth boundaries. ${reportSkeleton}Construction notes, CLI indexes, and case catalogs stay in the skill tree and are not part of this block.
${BOUND_METHOD_MARKER}
${boundMethod()}
</jyotish-skill>
<jyotish-shared-method>
${sharedConsultationMethodMarkdown()}
</jyotish-shared-method>`;
}
/** Method body + marker, without the natal Level 2 report skeleton. Window agents use this. */
export const jyotishSkillMethodCoreBlock = methodBlock("");
/** Natal chart-answering block: core method plus the Level 2 report skeleton. */
export const jyotishSkillMethodBlock = methodBlock(NATAL_REPORT_SKELETON);
/**
* Withdraw the activation tools while keeping `skill_read`.
*
* `providesSkillDiscovery: "on-demand"` is Mastra's declaration that the caller
* owns skill discovery and instruction loading, which is now literally true.
* Measured against this package: `skills` alone yields skill, skill_search and
* skill_read; with this marker present it yields skill_read only, so the long
* tail of references the method names by path stays reachable.
*
* The declaration is the point, but a processor has to do something, so it does
* the thing that only it is positioned to do: confirm the method really did
* arrive in the system prompt. Withdrawing the activation tool means an agent
* assembled without the method block would no longer fail loudly - it would
* simply answer without method, which is the failure this change exists to
* remove. A prompt that cannot be read is left alone; only one that can be read
* and has no method in it stops the run.
*
* Every step is checked rather than the first one: the chart-answering agents
* that bind this skill share this object, so remembering that one of them had
* its method would stop the others from ever being asked.
*/
export const jyotishSkillBoundProcessor: Processor & { processInputStep: NonNullable<Processor["processInputStep"]> } = {
id: "jyotish-skill-bound",
name: "Jyotish Skill Bound",
providesSkillDiscovery: "on-demand",
processInputStep({ messageList, abort }) {
const system = messageList?.getAllSystemMessages?.();
if (!Array.isArray(system) || system.length === 0) return;
if (!collectStrings(system).includes(BOUND_METHOD_MARKER)) {
abort(`Jyotish skill method is not bound into the system prompt for ${skill.name}`);
}
},
};
/**
* Every string anywhere in a system message, joined. A system message's content
* is a string in some shapes and a list of parts in others, and serialising the
* whole thing instead would escape the quotes in the marker and never match it.
*/
function collectStrings(value: unknown, found: string[] = []): string {
if (typeof value === "string") found.push(value);
else if (Array.isArray(value)) for (const item of value) collectStrings(item, found);
else if (value && typeof value === "object") for (const item of Object.values(value)) collectStrings(item, found);
return found.join("\n");
}
/** Chart-answering agents bind the runtime excerpt this way. General and
* onboarding agents do not: they cannot make natal claims. Fresh arrays per
* agent, because the loader takes ownership of the ones it is handed. */
export function jyotishSkillBinding() {
return {
skills: [jyotishSkillRuntimePath],
inputProcessors: [jyotishSkillBoundProcessor],
};
}