feat: 为 gitbundle 装配 required skills (#138)
* Add gitbundle required skills validation * fix: 限定 required skill blocked result 覆盖 --------- Co-authored-by: AgentRun Codex <agentrun@example.invalid> Co-authored-by: Codex <codex@pikas.tech>
This commit is contained in:
@@ -117,7 +117,7 @@ export function failureKindFromError(error: unknown): FailureKind {
|
||||
|
||||
export function terminalStatusForFailure(failureKind: FailureKind): TerminalStatus {
|
||||
if (failureKind === "cancelled") return "cancelled";
|
||||
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid" || failureKind === "prompt-unavailable" || failureKind === "prompt-too-large" || failureKind === "skill-unavailable") return "blocked";
|
||||
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid" || failureKind === "prompt-unavailable" || failureKind === "prompt-too-large" || failureKind === "required-skill-unavailable" || failureKind === "skill-unavailable") return "blocked";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface MaterializedSkillRef {
|
||||
manifestBytes: number;
|
||||
manifestSha256: string;
|
||||
summary: string;
|
||||
sourceBundle: JsonRecord | null;
|
||||
}
|
||||
|
||||
interface GitCheckout {
|
||||
@@ -91,7 +92,8 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
const defaultCheckout = await checkoutFor(defaultSource);
|
||||
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
|
||||
const tools = await prepareGitBundleTools(workspacePath, env);
|
||||
const skills = await discoverGitBundleSkills(workspacePath);
|
||||
const skills = await discoverGitBundleSkills(workspacePath, materializedBundles);
|
||||
const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items);
|
||||
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
|
||||
return {
|
||||
@@ -116,6 +118,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
},
|
||||
tools: tools.event,
|
||||
skillDirs: skills.event,
|
||||
requiredSkills: requiredSkills.event,
|
||||
promptRefs: prompts.event,
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
@@ -215,7 +218,7 @@ async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessE
|
||||
const names: string[] = [];
|
||||
const items: JsonRecord[] = [];
|
||||
if (installedBinPath) await mkdir(installedBinPath, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(sourceBinPath, entry.name);
|
||||
const text = await readFile(filePath, "utf8");
|
||||
@@ -283,7 +286,7 @@ async function materializePromptRefs(checkoutPath: string, refs: NonNullable<Res
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverGitBundleSkills(workspacePath: string): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
async function discoverGitBundleSkills(workspacePath: string, bundles: MaterializedGitBundle[]): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
const skillsDir = path.join(workspacePath, ".agents", "skills");
|
||||
let entries;
|
||||
try {
|
||||
@@ -294,22 +297,24 @@ async function discoverGitBundleSkills(workspacePath: string): Promise<{ items:
|
||||
}
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const aggregateAs = entry.name;
|
||||
const relativeManifestPath = `.agents/skills/${aggregateAs}/SKILL.md`;
|
||||
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
|
||||
const sourceBundle = skillSourceBundle(relativeManifestPath, bundles);
|
||||
let manifestText: string;
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, "utf8");
|
||||
} catch (error) {
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), valuesPrinted: false });
|
||||
eventItems.push({ name: aggregateAs, path: relativeManifestPath, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), sourceBundle, valuesPrinted: false });
|
||||
continue;
|
||||
}
|
||||
const bytes = Buffer.byteLength(manifestText, "utf8");
|
||||
const sha = sha256Text(manifestText);
|
||||
const summary = skillSummary(manifestText);
|
||||
items.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), summary, valuesPrinted: false });
|
||||
items.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary, sourceBundle });
|
||||
eventItems.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), sourceBundle, summary, valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
items,
|
||||
@@ -325,6 +330,70 @@ async function discoverGitBundleSkills(workspacePath: string): Promise<{ items:
|
||||
};
|
||||
}
|
||||
|
||||
function materializeRequiredSkills(refs: NonNullable<ResourceBundleRef["requiredSkills"]>, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord } {
|
||||
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], items: [], valuesPrinted: false } };
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
const missing: JsonRecord[] = [];
|
||||
const missingNames: string[] = [];
|
||||
for (const ref of refs) {
|
||||
const skill = byName.get(ref.name);
|
||||
if (!skill) {
|
||||
const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", valuesPrinted: false };
|
||||
missingNames.push(ref.name);
|
||||
missing.push(item);
|
||||
eventItems.push(item);
|
||||
continue;
|
||||
}
|
||||
items.push(skill);
|
||||
eventItems.push({ name: skill.name, path: skill.path, aggregateAs: skill.aggregateAs, status: "materialized", manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, summary: skill.summary, valuesPrinted: false });
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new AgentRunError("required-skill-unavailable", `required resource skill ${missingNames.join(", ")} is not materialized`, {
|
||||
httpStatus: 400,
|
||||
details: {
|
||||
required: refs.map((ref) => ref.name),
|
||||
missing,
|
||||
available: skills.map((skill) => ({ name: skill.name, path: skill.path, manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, valuesPrinted: false })),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
items,
|
||||
event: {
|
||||
count: refs.length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
items: eventItems,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function skillSourceBundle(relativePath: string, bundles: MaterializedGitBundle[]): JsonRecord | null {
|
||||
const match = bundles
|
||||
.filter((bundle) => relativePathMatchesTarget(relativePath, bundle.targetPath))
|
||||
.sort((left, right) => right.targetPath.length - left.targetPath.length)[0];
|
||||
if (!match) return null;
|
||||
return {
|
||||
name: match.name,
|
||||
repoUrl: match.repoUrl,
|
||||
commitId: match.commitId,
|
||||
requestedCommitId: match.requestedCommitId,
|
||||
requestedRef: match.requestedRef,
|
||||
subpath: match.subpath,
|
||||
targetPath: match.targetPath,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function relativePathMatchesTarget(relativePath: string, targetPath: string): boolean {
|
||||
const normalizedTarget = targetPath.replace(/\/+$/u, "");
|
||||
return relativePath === normalizedTarget || relativePath.startsWith(`${normalizedTarget}/`);
|
||||
}
|
||||
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skills.length === 0) return undefined;
|
||||
const sections: string[] = [
|
||||
|
||||
+17
-5
@@ -30,6 +30,13 @@ interface CommandExecutionResult extends JsonRecord {
|
||||
failureKind: FailureKind | null;
|
||||
}
|
||||
|
||||
interface RunnerFailure {
|
||||
terminalStatus: TerminalStatus;
|
||||
failureKind: FailureKind;
|
||||
message: string;
|
||||
details?: JsonRecord | null;
|
||||
}
|
||||
|
||||
export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
const api = new RunnerManagerApi(options.managerUrl);
|
||||
const targetRun = await api.getRun(options.runId);
|
||||
@@ -70,7 +77,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
let resourceEnv: NodeJS.ProcessEnv | undefined;
|
||||
let initialPrompt: InitialPromptAssembly | undefined;
|
||||
let materializationAttempted = false;
|
||||
let materializationFailure: { failureKind: FailureKind; terminalStatus: TerminalStatus; message: string } | null = null;
|
||||
let materializationFailure: RunnerFailure | null = null;
|
||||
let backendSession: BackendSession | null = null;
|
||||
|
||||
try {
|
||||
@@ -104,7 +111,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
}
|
||||
} catch (error) {
|
||||
const failureKind = failureKindFromError(error);
|
||||
materializationFailure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error) };
|
||||
materializationFailure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error), details: failureDetailsFromError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +314,10 @@ async function reportNonTerminalCommandFailure(api: RunnerManagerApi, runId: str
|
||||
await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message, ...(control ? { threadId: control.threadId, turnId: control.turnId } : {}) });
|
||||
}
|
||||
|
||||
function failureDetailsFromError(error: unknown): JsonRecord | null {
|
||||
return error instanceof AgentRunError ? error.details : null;
|
||||
}
|
||||
|
||||
function steerPrompt(payload: JsonRecord): string | null {
|
||||
for (const key of ["prompt", "message", "text"]) {
|
||||
const value = payload[key];
|
||||
@@ -466,9 +477,10 @@ function annotateCommandEvent(event: BackendEvent, commandId: string, attemptId:
|
||||
return { ...event, payload: { ...event.payload, commandId, attemptId, runnerId } };
|
||||
}
|
||||
|
||||
async function reportCommandFailure(api: RunnerManagerApi, runId: string, commandId: string, runner: RunnerRecord, attemptId: string, failure: { terminalStatus: TerminalStatus; failureKind: FailureKind; message: string }, phase: string, options: { terminalRun?: boolean } = {}): Promise<CommandExecutionResult> {
|
||||
await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id } });
|
||||
await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id } });
|
||||
async function reportCommandFailure(api: RunnerManagerApi, runId: string, commandId: string, runner: RunnerRecord, attemptId: string, failure: RunnerFailure, phase: string, options: { terminalRun?: boolean } = {}): Promise<CommandExecutionResult> {
|
||||
const details = failure.details ? { details: failure.details } : {};
|
||||
await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id, ...details } });
|
||||
await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id, ...details } });
|
||||
await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
|
||||
if (options.terminalRun === true) await api.reportStatus(runId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
|
||||
return { commandId, terminalStatus: failure.terminalStatus, failureKind: failure.failureKind } as CommandExecutionResult;
|
||||
|
||||
Reference in New Issue
Block a user