Activating the skill answered with the entrypoint plus a flat listing of every file in the package, and nothing carried over between requests: 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 on every turn and paid for that listing every time, twice more whenever a retry opened a fresh model loop. The method is now read from the hash-verified package and bound into the instructions, and the activation tools are withdrawn while skill_read stays. The agents also stopped loading from the working-tree view, whose reference listing no hash covered - only SKILL.md was compared - so what the model sees is finally what the registry pinned. Withdrawing an activation the model could forget also removes the failure it mostly produced: the contract no longer waits on a model action for method, and the no-birth-time path has no contract left to repair. Measured against this package: activation 118,352 bytes, bound method 47,289. Co-authored-by: Cursor <cursoragent@cursor.com>
88 lines
3.9 KiB
TypeScript
88 lines
3.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import test from "node:test";
|
|
import { Agent } from "@mastra/core/agent";
|
|
import {
|
|
jyotishSkillBinding,
|
|
jyotishSkillBoundProcessor,
|
|
jyotishSkillMethodBlock,
|
|
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);
|
|
}
|
|
|
|
test("the method the model follows is the published one, not the working tree's", () => {
|
|
const published = readFileSync(resolve(jyotishSkillPackage.resolvedPath, "SKILL.md"), "utf8");
|
|
const body = published.slice(published.indexOf("---", 3) + 4).trim();
|
|
|
|
assert.ok(jyotishSkillMethodBlock.includes(body));
|
|
assert.match(jyotishSkillMethodBlock, new RegExp(`version="${jyotishSkillPackage.version}"`));
|
|
// The route into the loader is the hash-checked package, so the reference and
|
|
// script listing the model can reach is the one the registry hash covers.
|
|
assert.ok(jyotishSkillRuntimePath.includes(jyotishSkillPackage.sha256));
|
|
assert.ok(jyotishSkillRuntimePath.endsWith(jyotishSkillPackage.name));
|
|
});
|
|
|
|
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([]));
|
|
});
|