窗口 Agent 注入不含本命骨架的方法块;tripwire abort 走 skill_binding_failed; 无工具但有正文降级交付;应期问题仍先调工具。BUG-957 等窗口线验证后再做。
187 lines
8.1 KiB
TypeScript
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],
|
|
};
|
|
}
|