Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skill-discovery-symlinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Support symlinked skill files and skill packages during authored skill discovery.
2 changes: 1 addition & 1 deletion docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ eve generates `SKILL.md` from `markdown`, and each `files` entry becomes a packa

## Skills are scoped per agent

Skills are scoped to the agent that declares them. A [subagent](./subagents)'s `skills/` are invisible to the root agent, and the reverse holds too. There's no shared-skill mechanism, so put shared executable helpers in `lib/`.
Skills are scoped to the agent that declares them. A [subagent](./subagents)'s `skills/` are invisible to the root agent, and the reverse holds too. To reuse an authored skill package locally, symlink its markdown file or package directory into each agent's own `skills/` directory. eve follows those links during discovery and copies their resolved contents into the compiled workspace resources, so deployment artifacts remain self-contained. Put shared executable helpers in `lib/`.

## Read skill files at runtime

Expand Down
2 changes: 1 addition & 1 deletion docs/subagents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ A declared subagent inherits nothing from the root's authored slots. Discovery t
| Channels | Root-only | Root-only |
| Schedules | Root-only | Root-only |

For a declared subagent this means duplicating anything the child needs. When two subagents need the same procedure, copy the markdown under each `skills/` directory, or share typed helpers via `lib/`. The sandbox does not inherit from the parent; it falls back to the framework default unless the subagent authors `subagents/<id>/sandbox.ts` or seeds files via `subagents/<id>/sandbox/workspace/`.
For a declared subagent this means explicitly placing anything the child needs in its own authored slots. When two subagents need the same procedure, copy the markdown or symlink the shared skill file/package into each `skills/` directory; eve dereferences linked skill contents into self-contained compile artifacts. Share typed helpers via `lib/`. The sandbox does not inherit from the parent; it falls back to the framework default unless the subagent authors `subagents/<id>/sandbox.ts` or seeds files via `subagents/<id>/sandbox/workspace/`.

The built-in `agent` tool is the exception. Its children share the parent's sandbox and tools because they are copies of the same agent working on the same files.

Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/compiler/workspace-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ async function materializeSkill(input: {
const skillRoot = join(input.nodeRoot, RESOURCE_SKILLS_DIRECTORY, input.skill.name);

if (input.skill.sourceKind === "skill-package") {
await cp(input.skill.rootPath, skillRoot, { recursive: true });
await cp(input.skill.rootPath, skillRoot, { dereference: true, recursive: true });
return;
}

Expand Down
156 changes: 156 additions & 0 deletions packages/eve/src/discover/skills.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

import { describe, expect, it } from "vitest";
Expand All @@ -12,6 +14,43 @@ import {
discoverSkills,
} from "#discover/skills.js";

async function withDiskAgentProject<T>(
callback: (paths: { agentRoot: string; appRoot: string; tempRoot: string }) => Promise<T>,
): Promise<T> {
const tempRoot = await mkdtemp(join(tmpdir(), "eve-skills-"));
const appRoot = join(tempRoot, "app");
const agentRoot = join(appRoot, "agent");

try {
await mkdir(join(agentRoot, "skills"), { recursive: true });
return await callback({ agentRoot, appRoot, tempRoot });
} finally {
await rm(tempRoot, { force: true, recursive: true });
}
}

async function trySymlink(
target: string,
path: string,
type?: "dir" | "file" | "junction",
): Promise<boolean> {
try {
await symlink(target, path, type);
return true;
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "EPERM" || error.code === "EACCES")
) {
return false;
}

throw error;
}
}

describe("discoverSkills (memory)", () => {
it("discovers packaged, markdown, and module-backed skills", async () => {
const project = buildMemoryAgentProject({
Expand Down Expand Up @@ -237,3 +276,120 @@ describe("discoverSkills (memory)", () => {
]);
});
});

describe("discoverSkills (disk)", () => {
it("discovers flat markdown skills through file symlinks", async ({ skip }) => {
await withDiskAgentProject(async ({ agentRoot, tempRoot }) => {
const sharedSkillPath = join(tempRoot, "shared", "research-source.md");
await mkdir(join(tempRoot, "shared"), { recursive: true });
await writeFile(
sharedSkillPath,
[
"---",
"description: Research complex marketplace questions before replying.",
"---",
"Use the shared research process.",
].join("\n"),
);

const symlinkPath = join(agentRoot, "skills", "market-research.md");
if (!(await trySymlink(sharedSkillPath, symlinkPath, "file"))) {
skip("file symlink creation is unavailable on this platform");
}

const result = await discoverSkills({ agentRoot });

expect(result.diagnostics).toEqual([]);
expect(result.skills).toEqual([
{
definition: {
description: "Research complex marketplace questions before replying.",
markdown: "Use the shared research process.",
},
sourceKind: "markdown",
logicalPath: "skills/market-research.md",
sourceId: "skills/market-research.md",
},
]);
});
});

it("discovers packaged skills whose SKILL.md is a file symlink", async ({ skip }) => {
await withDiskAgentProject(async ({ agentRoot, tempRoot }) => {
const sharedSkillPath = join(tempRoot, "shared", "SKILL.md");
const skillRoot = join(agentRoot, "skills", "market-research");
const symlinkPath = join(skillRoot, "SKILL.md");
await mkdir(join(tempRoot, "shared"), { recursive: true });
await mkdir(skillRoot, { recursive: true });
await writeFile(
sharedSkillPath,
[
"---",
"description: Research complex marketplace questions before replying.",
"---",
"Use the packaged research process.",
].join("\n"),
);

if (!(await trySymlink(sharedSkillPath, symlinkPath, "file"))) {
skip("file symlink creation is unavailable on this platform");
}

const result = await discoverSkills({ agentRoot });

expect(result.diagnostics).toEqual([]);
expect(result.skills).toEqual([
{
description: "Research complex marketplace questions before replying.",
sourceKind: "skill-package",
logicalPath: "skills/market-research/SKILL.md",
markdown: "Use the packaged research process.",
name: "market-research",
rootPath: skillRoot,
skillFilePath: symlinkPath,
skillId: "market-research",
sourceId: "skills/market-research/SKILL.md",
},
]);
});
});

it("discovers packaged skills through directory symlinks or junctions", async ({ skip }) => {
await withDiskAgentProject(async ({ agentRoot, tempRoot }) => {
const sharedSkillRoot = join(tempRoot, "shared", "market-research");
const symlinkPath = join(agentRoot, "skills", "market-research");
await mkdir(sharedSkillRoot, { recursive: true });
await writeFile(
join(sharedSkillRoot, "SKILL.md"),
[
"---",
"description: Research complex marketplace questions before replying.",
"---",
"Use the directory-linked research process.",
].join("\n"),
);

const linkType = process.platform === "win32" ? "junction" : "dir";
if (!(await trySymlink(sharedSkillRoot, symlinkPath, linkType))) {
skip("directory symlink or junction creation is unavailable on this platform");
}

const result = await discoverSkills({ agentRoot });

expect(result.diagnostics).toEqual([]);
expect(result.skills).toEqual([
{
description: "Research complex marketplace questions before replying.",
sourceKind: "skill-package",
logicalPath: "skills/market-research/SKILL.md",
markdown: "Use the directory-linked research process.",
name: "market-research",
rootPath: symlinkPath,
skillFilePath: join(symlinkPath, "SKILL.md"),
skillId: "market-research",
sourceId: "skills/market-research/SKILL.md",
},
]);
});
});
});
49 changes: 41 additions & 8 deletions packages/eve/src/discover/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
classifySkillsDirectoryEntry,
getDirectoryEntryType,
getSupportedModuleBaseName,
type DirectoryEntryType,
normalizeLogicalPath,
} from "#discover/filesystem.js";
import { readSortedDirectoryEntries } from "#discover/grammar.js";
Expand Down Expand Up @@ -96,9 +97,10 @@ export async function discoverSkills(input: DiscoverSkillsInput): Promise<Discov
const entries = await readSortedDirectoryEntries(source, skillsDirectoryPath);

for (const entry of entries) {
const entryPath = join(skillsDirectoryPath, entry.name);
const discoveredSkill = await discoverOneSkill({
entryName: entry.name,
entryType: getDirectoryEntryType(entry),
entryType: await resolveSkillEntryType(source, entryPath, getDirectoryEntryType(entry)),
skillsDirectoryPath,
skillsLogicalPath,
source,
Expand Down Expand Up @@ -203,9 +205,25 @@ async function discoverPackagedSkill(input: {
skillId: string | null;
}> {
const entries = await readSortedDirectoryEntries(input.source, input.skillRootPath);
const skillFileName = entries.find(
(entry) => entry.isFile() && entry.name.toLowerCase() === "skill.md",
)?.name;
let skillFileName: string | undefined;

for (const entry of entries) {
if (entry.name.toLowerCase() !== "skill.md") {
continue;
}

const entryType = await resolveSkillEntryType(
input.source,
join(input.skillRootPath, entry.name),
getDirectoryEntryType(entry),
);

if (entryType === "file") {
skillFileName = entry.name;
break;
}
}

const skillFilePath = join(input.skillRootPath, skillFileName ?? "SKILL.md");
const logicalPath = normalizeLogicalPath(
join(input.logicalSkillsPath, input.skillId, skillFileName ?? "SKILL.md"),
Expand Down Expand Up @@ -393,11 +411,13 @@ async function discoverSkillPackagePaths(
} = {};

for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const entryType = await resolveSkillEntryType(
source,
join(skillRootPath, entry.name),
getDirectoryEntryType(entry),
);

switch (classifySkillPackageEntry(entry.name, getDirectoryEntryType(entry))) {
switch (classifySkillPackageEntry(entry.name, entryType)) {
case "skill-assets-directory":
packagePaths.assetsPath = join(skillRootPath, entry.name);
break;
Expand All @@ -415,6 +435,19 @@ async function discoverSkillPackagePaths(
return packagePaths;
}

async function resolveSkillEntryType(
source: ProjectSource,
entryPath: string,
entryType: DirectoryEntryType,
): Promise<DirectoryEntryType> {
if (entryType !== "other") {
return entryType;
}

const targetType = await source.stat(entryPath);
return targetType === "directory" || targetType === "file" ? targetType : "other";
}

function formatSkillDiscoveryError(skillFilePath: string, error: unknown): string {
return `Invalid authored skill frontmatter in "${skillFilePath}": ${toErrorMessage(error)}`;
}
Expand Down
73 changes: 72 additions & 1 deletion packages/eve/test/scenarios/compile-agent.scenario.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { lstat, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
Expand Down Expand Up @@ -840,6 +840,77 @@ describe("compileAgent", () => {
expect(moduleMapText).not.toContain("Find primary sources");
});

it("dereferences linked skill packages into self-contained workspace resources", async ({
skip,
}) => {
const { agentRoot, appRoot } = await createAppRoot(
"eve-compile-linked-skill-package-",
APP_ROOT_OPTIONS,
);
const sharedSkillRoot = join(appRoot, "shared-skills", "research");
const linkedSkillRoot = join(agentRoot, "skills", "research");

await mkdir(join(sharedSkillRoot, "references"), { recursive: true });
await mkdir(join(agentRoot, "skills"), { recursive: true });
await writeFile(join(agentRoot, "agent.mjs"), 'export default { model: "openai/gpt-5.4" };\n');
await writeFile(join(agentRoot, "instructions.md"), "You are a precise assistant.");
await writeFile(
join(sharedSkillRoot, "SKILL.md"),
[
"---",
"description: Research unfamiliar topics.",
"---",
"Gather evidence first.",
].join("\n"),
);
await writeFile(
join(sharedSkillRoot, "references", "checklist.md"),
"# Checklist\n\n- Find primary sources.\n",
);
try {
await symlink(
sharedSkillRoot,
linkedSkillRoot,
process.platform === "win32" ? "junction" : "dir",
);
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "EPERM" || error.code === "EACCES")
) {
skip("directory symlink or junction creation is unavailable on this platform");
}

throw error;
}

const result = await compileAgent({ startPath: appRoot });
const compiledSkillRoot = join(
result.paths.compileDirectoryPath,
"workspace-resources",
ROOT_COMPILED_AGENT_NODE_ID,
"skills",
"research",
);

await rm(sharedSkillRoot, { recursive: true });

const [skillStats, referenceStats, skillMarkdown, checklist] = await Promise.all([
lstat(join(compiledSkillRoot, "SKILL.md")),
lstat(join(compiledSkillRoot, "references", "checklist.md")),
readFile(join(compiledSkillRoot, "SKILL.md"), "utf8"),
readFile(join(compiledSkillRoot, "references", "checklist.md"), "utf8"),
]);

expect(skillStats.isSymbolicLink()).toBe(false);
expect(referenceStats.isSymbolicLink()).toBe(false);
expect(skillMarkdown).toContain("Gather evidence first.");
expect(checklist).toBe("# Checklist\n\n- Find primary sources.\n");
expect(result.manifest.workspaceResourceRoot?.contentHash).toMatch(/^[a-f0-9]{64}$/);
});

it("compiles nested authored tools using the path-derived tool name", async () => {
const { agentRoot, appRoot } = await createAppRoot(
"eve-compile-nested-tools-",
Expand Down
Loading