refactor(agent): harden harness session semantics
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import ignore from "ignore";
|
||||
import { parse } from "yaml";
|
||||
import { type ExecutionEnv, type FileInfo, getOrUndefined, type Result, type Skill } from "./types.js";
|
||||
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
@@ -8,10 +8,19 @@ const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
|
||||
|
||||
type IgnoreMatcher = ReturnType<typeof ignore>;
|
||||
|
||||
export type SkillDiagnosticCode =
|
||||
| "file_info_failed"
|
||||
| "list_failed"
|
||||
| "read_failed"
|
||||
| "parse_failed"
|
||||
| "invalid_metadata";
|
||||
|
||||
/** Warning produced while loading skills. */
|
||||
export interface SkillDiagnostic {
|
||||
/** Diagnostic severity. Currently only warnings are emitted. */
|
||||
type: "warning";
|
||||
/** Stable diagnostic code. */
|
||||
code: SkillDiagnosticCode;
|
||||
/** Human-readable diagnostic message. */
|
||||
message: string;
|
||||
/** Path associated with the diagnostic. */
|
||||
@@ -44,8 +53,20 @@ export async function loadSkills(
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
|
||||
const rootInfo = getOrUndefined(await env.fileInfo(dir));
|
||||
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
|
||||
const rootInfoResult = await env.fileInfo(dir);
|
||||
if (!rootInfoResult.ok) {
|
||||
if (rootInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: rootInfoResult.error.message,
|
||||
path: dir,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const rootInfo = rootInfoResult.value;
|
||||
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
|
||||
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
|
||||
skills.push(...result.skills);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
@@ -89,18 +110,34 @@ async function loadSkillsFromDirInternal(
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
|
||||
const dirInfo = getOrUndefined(await env.fileInfo(dir));
|
||||
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
|
||||
const dirInfoResult = await env.fileInfo(dir);
|
||||
if (!dirInfoResult.ok) {
|
||||
if (dirInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: dirInfoResult.error.message,
|
||||
path: dir,
|
||||
});
|
||||
}
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
const dirInfo = dirInfoResult.value;
|
||||
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };
|
||||
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
|
||||
|
||||
const entries = getOrUndefined(await env.listDir(dir));
|
||||
if (!entries) return { skills, diagnostics };
|
||||
const entriesResult = await env.listDir(dir);
|
||||
if (!entriesResult.ok) {
|
||||
diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir });
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
const entries = entriesResult.value;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name !== "SKILL.md") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry);
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
if (kind !== "file") continue;
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
if (ignoreMatcher.ignores(relPath)) continue;
|
||||
@@ -114,7 +151,7 @@ async function loadSkillsFromDirInternal(
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry);
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
if (!kind) continue;
|
||||
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
@@ -137,16 +174,36 @@ async function loadSkillsFromDirInternal(
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
|
||||
async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise<void> {
|
||||
async function addIgnoreRules(
|
||||
env: ExecutionEnv,
|
||||
ig: IgnoreMatcher,
|
||||
dir: string,
|
||||
rootDir: string,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
): Promise<void> {
|
||||
const relativeDir = relativeEnvPath(rootDir, dir);
|
||||
const prefix = relativeDir ? `${relativeDir}/` : "";
|
||||
|
||||
for (const filename of IGNORE_FILE_NAMES) {
|
||||
const ignorePath = joinEnvPath(dir, filename);
|
||||
const info = getOrUndefined(await env.fileInfo(ignorePath));
|
||||
if (info?.kind !== "file") continue;
|
||||
const info = await env.fileInfo(ignorePath);
|
||||
if (!info.ok) {
|
||||
if (info.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: info.error.message,
|
||||
path: ignorePath,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (info.value.kind !== "file") continue;
|
||||
const content = await env.readTextFile(ignorePath);
|
||||
if (!content.ok) continue;
|
||||
if (!content.ok) {
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
|
||||
continue;
|
||||
}
|
||||
const patterns = content.value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => prefixIgnorePattern(line, prefix))
|
||||
@@ -180,13 +237,13 @@ async function loadSkillFromFile(
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
if (!rawContent.ok) {
|
||||
diagnostics.push({ type: "warning", message: rawContent.error.message, path: filePath });
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath });
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
|
||||
if (!parsed.ok) {
|
||||
diagnostics.push({ type: "warning", message: parsed.error.message, path: filePath });
|
||||
diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath });
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
@@ -196,13 +253,13 @@ async function loadSkillFromFile(
|
||||
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
|
||||
|
||||
for (const error of validateDescription(description)) {
|
||||
diagnostics.push({ type: "warning", message: error, path: filePath });
|
||||
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
|
||||
}
|
||||
|
||||
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
|
||||
const name = frontmatterName || parentDirName;
|
||||
for (const error of validateName(name, parentDirName)) {
|
||||
diagnostics.push({ type: "warning", message: error, path: filePath });
|
||||
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
|
||||
}
|
||||
|
||||
if (!description || description.trim() === "") {
|
||||
@@ -255,17 +312,41 @@ function parseFrontmatter<T extends Record<string, unknown>>(
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
||||
return { ok: false, error: toError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
|
||||
async function resolveKind(
|
||||
env: ExecutionEnv,
|
||||
info: FileInfo,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
): Promise<"file" | "directory" | undefined> {
|
||||
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
||||
const canonicalPath = await env.canonicalPath(info.path);
|
||||
if (!canonicalPath.ok) return undefined;
|
||||
const target = getOrUndefined(await env.fileInfo(canonicalPath.value));
|
||||
if (!target) return undefined;
|
||||
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
|
||||
if (!canonicalPath.ok) {
|
||||
if (canonicalPath.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: canonicalPath.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const target = await env.fileInfo(canonicalPath.value);
|
||||
if (!target.ok) {
|
||||
if (target.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: target.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
|
||||
}
|
||||
|
||||
function joinEnvPath(base: string, child: string): string {
|
||||
|
||||
Reference in New Issue
Block a user