feat: assemble resource prompts and skills
This commit is contained in:
@@ -1,17 +1,45 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import { redactText } from "../common/redaction.js";
|
||||
import type { JsonRecord, ResourceBundleRef } from "../common/types.js";
|
||||
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js";
|
||||
import { stableHash } from "../common/validation.js";
|
||||
|
||||
const maxPromptRefBytes = 16 * 1024;
|
||||
const maxInitialPromptBytes = 64 * 1024;
|
||||
const skillSummaryChars = 600;
|
||||
|
||||
export interface MaterializedResourceBundle {
|
||||
workspacePath: string;
|
||||
binPath?: string;
|
||||
skillsDir?: string;
|
||||
initialPrompt?: InitialPromptAssembly;
|
||||
event: JsonRecord;
|
||||
}
|
||||
|
||||
interface MaterializedPromptRef {
|
||||
name: string;
|
||||
path: string;
|
||||
inject: "thread-start";
|
||||
required: boolean;
|
||||
text: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
interface MaterializedSkillRef {
|
||||
name: string;
|
||||
path: string;
|
||||
aggregateAs: string;
|
||||
required: boolean;
|
||||
registryPath: string;
|
||||
manifestBytes: number;
|
||||
manifestSha256: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
|
||||
if (!resourceBundleRef) return null;
|
||||
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
|
||||
@@ -32,9 +60,14 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir);
|
||||
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
|
||||
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []);
|
||||
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
|
||||
return {
|
||||
workspacePath,
|
||||
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}),
|
||||
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
event: {
|
||||
phase: "resource-bundle-materialized",
|
||||
kind: "git",
|
||||
@@ -46,6 +79,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
subdir: resourceBundleRef.subdir ?? null,
|
||||
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
|
||||
toolAliases: toolAliases.event,
|
||||
skillRefs: skills.event,
|
||||
promptRefs: prompts.event,
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
@@ -68,6 +104,144 @@ async function materializeToolAliases(checkoutPath: string, aliases: NonNullable
|
||||
return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } };
|
||||
}
|
||||
|
||||
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> {
|
||||
const items: MaterializedPromptRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
let totalBytes = 0;
|
||||
for (const ref of refs) {
|
||||
const promptPath = resolveBundlePath(checkoutPath, ref.path, `promptRefs.${ref.name}.path`);
|
||||
const required = ref.required === true;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(promptPath, "utf8");
|
||||
} catch (error) {
|
||||
if (required) throw new AgentRunError("prompt-unavailable", `required resource prompt ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } });
|
||||
eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "missing", valuesPrinted: false });
|
||||
continue;
|
||||
}
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxPromptRefBytes) throw new AgentRunError("prompt-too-large", `resource prompt ${ref.name} exceeds the per-file size limit`, { httpStatus: 400, details: { name: ref.name, path: ref.path, bytes, maxPromptRefBytes, valuesPrinted: false } });
|
||||
totalBytes += bytes;
|
||||
if (totalBytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled resource prompt exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxInitialPromptBytes, valuesPrinted: false } });
|
||||
const sha = sha256Text(text);
|
||||
items.push({ name: ref.name, path: ref.path, inject: "thread-start", required, text, bytes, sha256: sha });
|
||||
eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "materialized", sha256: sha, bytes, valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
items,
|
||||
event: {
|
||||
count: refs.length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
items: eventItems,
|
||||
totalBytes,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeSkillRefs(checkoutPath: string, workspacePath: string, refs: NonNullable<ResourceBundleRef["skillRefs"]>): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
|
||||
const skillsDir = path.join(workspacePath, ".agents", "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
for (const ref of refs) {
|
||||
const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`);
|
||||
const required = ref.required === true;
|
||||
let manifestText: string;
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, "utf8");
|
||||
} catch (error) {
|
||||
if (required) throw new AgentRunError("skill-unavailable", `required resource skill ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } });
|
||||
eventItems.push({ name: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false });
|
||||
continue;
|
||||
}
|
||||
const aggregateAs = ref.aggregateAs ?? ref.name;
|
||||
const sourceRoot = path.dirname(manifestPath);
|
||||
const targetRoot = path.join(skillsDir, aggregateAs);
|
||||
if (path.resolve(sourceRoot) !== path.resolve(targetRoot)) {
|
||||
await rm(targetRoot, { recursive: true, force: true });
|
||||
await cp(sourceRoot, targetRoot, { recursive: true, force: true, dereference: false });
|
||||
}
|
||||
const bytes = Buffer.byteLength(manifestText, "utf8");
|
||||
const sha = sha256Text(manifestText);
|
||||
const summary = skillSummary(manifestText);
|
||||
items.push({ name: ref.name, path: ref.path, aggregateAs, required, registryPath: path.join(targetRoot, "SKILL.md"), manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
eventItems.push({ name: ref.name, path: ref.path, aggregateAs, required, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(path.join(targetRoot, "SKILL.md")), summary, valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
items,
|
||||
skillsDir,
|
||||
event: {
|
||||
count: refs.length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
skillsDir: pathSummary(skillsDir),
|
||||
items: eventItems,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skillRefs.length === 0) return undefined;
|
||||
const sections: string[] = [
|
||||
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and skillRefs for the first thread-start turn only.",
|
||||
];
|
||||
for (const prompt of promptRefs) {
|
||||
sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n"));
|
||||
}
|
||||
if (skillRefs.length > 0) {
|
||||
const lines = [
|
||||
"## Resource Skills",
|
||||
"The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.",
|
||||
...skillRefs.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`),
|
||||
];
|
||||
sections.push(lines.join("\n"));
|
||||
}
|
||||
const text = sections.join("\n\n");
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillRefCount: skillRefs.length, valuesPrinted: false } });
|
||||
return {
|
||||
text,
|
||||
summary: {
|
||||
available: true,
|
||||
bytes,
|
||||
sha256: sha256Text(text),
|
||||
promptRefCount: promptRefs.length,
|
||||
promptRefNames: promptRefs.map((item) => item.name),
|
||||
skillRefCount: skillRefs.length,
|
||||
skillRefNames: skillRefs.map((item) => item.name),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function skillSummary(text: string): string {
|
||||
const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*/u.exec(text);
|
||||
if (frontmatter) {
|
||||
const descriptionLine = frontmatter[1]?.split(/\r?\n/u).find((line) => /^description\s*:/iu.test(line));
|
||||
if (descriptionLine) return trimSummary(descriptionLine.replace(/^description\s*:\s*/iu, "").trim().replace(/^['"]|['"]$/gu, ""));
|
||||
}
|
||||
const line = text.split(/\r?\n/u).map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#") && entry !== "---");
|
||||
return trimSummary(line ?? "");
|
||||
}
|
||||
|
||||
function trimSummary(value: string): string {
|
||||
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||
return normalized.length > skillSummaryChars ? `${normalized.slice(0, skillSummaryChars)}...` : normalized;
|
||||
}
|
||||
|
||||
function sha256Text(text: string): string {
|
||||
return createHash("sha256").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function fileErrorSummary(error: unknown): JsonRecord {
|
||||
const record = typeof error === "object" && error !== null ? error as { code?: unknown; message?: unknown } : {};
|
||||
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
|
||||
}
|
||||
|
||||
function aliasWrapper(kind: string, target: string): string {
|
||||
if (kind === "node-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec node ${shellArg(target)} "$@"\n`;
|
||||
if (kind === "bun-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec bun ${shellArg(target)} "$@"\n`;
|
||||
|
||||
Reference in New Issue
Block a user